@fede0089/skill-eval 1.0.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/LICENSE +21 -0
- package/README.md +170 -0
- package/dist/commands/functional.js +225 -0
- package/dist/commands/rate.js +20 -0
- package/dist/commands/show.js +43 -0
- package/dist/commands/trigger.js +154 -0
- package/dist/commands/view.js +30 -0
- package/dist/core/agent-pool.js +40 -0
- package/dist/core/config.js +58 -0
- package/dist/core/environment.js +83 -0
- package/dist/core/errors.js +29 -0
- package/dist/core/eval-runner.js +306 -0
- package/dist/core/evaluator.js +242 -0
- package/dist/core/preflight.js +36 -0
- package/dist/core/reporters/html-reporter.js +354 -0
- package/dist/core/reporters/index.js +9 -0
- package/dist/core/reporters/json-reporter.js +7 -0
- package/dist/core/reporters/reporter.js +1 -0
- package/dist/core/runner.js +75 -0
- package/dist/core/runners/factory.js +18 -0
- package/dist/core/runners/gemini-cli.runner.js +138 -0
- package/dist/core/runners/index.js +3 -0
- package/dist/core/runners/runner.interface.js +1 -0
- package/dist/core/statistics.js +79 -0
- package/dist/core/trial-utils.js +58 -0
- package/dist/index.js +80 -0
- package/dist/reporters/html-reporter.js +384 -0
- package/dist/reporters/index.js +2 -0
- package/dist/reporters/json-reporter.js +10 -0
- package/dist/reporters/reporter.js +1 -0
- package/dist/runners/gemini-cli/index.js +1 -0
- package/dist/runners/gemini-cli/runner.js +231 -0
- package/dist/runners/index.js +2 -0
- package/dist/runners/registry.js +16 -0
- package/dist/runners/runner.interface.js +1 -0
- package/dist/types/index.js +1 -0
- package/dist/utils/eval-loader.js +66 -0
- package/dist/utils/exec.js +9 -0
- package/dist/utils/logger.js +80 -0
- package/dist/utils/ndjson.js +85 -0
- package/dist/utils/table-renderer.js +229 -0
- package/dist/utils/ui.js +166 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Federico Mete
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# skill-eval
|
|
2
|
+
|
|
3
|
+
A CLI tool for evaluating Agent Skills locally. Tests whether your skill triggers reliably and produces the right output, using an LLM as the judge.
|
|
4
|
+
|
|
5
|
+
## Why skill evals?
|
|
6
|
+
|
|
7
|
+
Skills are instructions that change how the agent behaves. But a single successful run isn't enough to trust one — agents are non-deterministic, and a good isolated result can be exactly that: an isolated case.
|
|
8
|
+
|
|
9
|
+
Skill evals let you turn that intuition into evidence: run several comparable tasks with and without the skill, measure them against the same criteria, and validate whether the agent improves consistently against a baseline.
|
|
10
|
+
|
|
11
|
+
## How it works
|
|
12
|
+
|
|
13
|
+
For each eval prompt, skill-eval spins up parallel agent processes — some with the skill installed, others without (the baseline). Each agent runs headlessly and produces a transcript. An LLM judge then grades each transcript against your expectations. Results are aggregated into **pass@k** metrics, giving you a clear measure of how much your skill actually improves the agent's behavior versus the unassisted baseline.
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
eval prompt
|
|
17
|
+
│
|
|
18
|
+
┌───────▼───────┐
|
|
19
|
+
│ skill-eval │
|
|
20
|
+
└───────┬───────┘
|
|
21
|
+
│
|
|
22
|
+
┌───────────┴───────────┐
|
|
23
|
+
─ with skill ─ ─ baseline ─
|
|
24
|
+
┌──────┴──────┐ ┌─────┴──────┐
|
|
25
|
+
agent 1 agent 2 agent 3 agent 4
|
|
26
|
+
│ │ │ │
|
|
27
|
+
judge judge judge judge
|
|
28
|
+
└──────┬──────┘ └──────┬──────┘
|
|
29
|
+
└──────────┬────────────┘
|
|
30
|
+
│
|
|
31
|
+
pass@k
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
> The `trigger` command only runs with-skill trials and checks whether the skill dispatch tool was actually invoked — no judge or baseline needed.
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
git clone <this-repo>
|
|
40
|
+
cd skill-eval
|
|
41
|
+
npm install
|
|
42
|
+
npm run build
|
|
43
|
+
npm link
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
This makes `skill-eval` available globally in your terminal.
|
|
47
|
+
|
|
48
|
+
**Requirements:** Node.js, and the agent CLI you want to evaluate (e.g. `gemini`) installed and on `$PATH`.
|
|
49
|
+
|
|
50
|
+
## Commands
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
# Checks that the skill is triggered (invoked) for each prompt
|
|
54
|
+
skill-eval trigger --workspace <path> --skill <path> [options] [agent]
|
|
55
|
+
|
|
56
|
+
# Checks that the skill produces correct output, measured against a baseline
|
|
57
|
+
skill-eval functional --workspace <path> --skill <path> [options] [agent]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Options
|
|
61
|
+
|
|
62
|
+
| Flag | Required | Default | Description |
|
|
63
|
+
|------|----------|---------|-------------|
|
|
64
|
+
| `--workspace <path>` | yes | — | Path to the repo the agent will run in |
|
|
65
|
+
| `--skill <path>` | yes | — | Path to the skill directory |
|
|
66
|
+
| `--agents <number>` | no | `4` | Number of parallel agent processes |
|
|
67
|
+
| `--trials <number>` | no | `3` | Trials per task (for pass@k) |
|
|
68
|
+
| `--timeout <seconds>` | no | none | Kill the agent after this many seconds |
|
|
69
|
+
| `--eval-id <id>` | no | all | Run only the eval with this numeric ID |
|
|
70
|
+
| `-v, --debug` | no | `false` | Enable verbose debug logging |
|
|
71
|
+
| `[agent]` | no | `gemini-cli` | Agent backend to use |
|
|
72
|
+
|
|
73
|
+
### Skill directory structure
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
my-skill/
|
|
77
|
+
├── SKILL.md # skill definition (required)
|
|
78
|
+
└── evals/ # evaluation suite (required)
|
|
79
|
+
├── my-evals.json # one or more eval files (*.json)
|
|
80
|
+
└── config/ # runner configuration (optional but often needed)
|
|
81
|
+
└── gemini-cli/ # runner-specific config folder
|
|
82
|
+
└── settings.json # copied to <worktree>/.gemini/ before each trial
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
All `.json` files in `evals/` are loaded and merged into a single suite — you can split them by feature or regression category.
|
|
86
|
+
|
|
87
|
+
**Trigger eval** — `id` must be a unique integer across all eval files:
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"skill_name": "my-skill",
|
|
91
|
+
"evals": [
|
|
92
|
+
{ "id": 1, "prompt": "Do the thing that my skill handles" }
|
|
93
|
+
]
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
**Functional eval** — add `expectations` for the LLM judge to evaluate:
|
|
98
|
+
```json
|
|
99
|
+
{
|
|
100
|
+
"skill_name": "my-skill",
|
|
101
|
+
"evals": [
|
|
102
|
+
{
|
|
103
|
+
"id": 1,
|
|
104
|
+
"prompt": "Create a file called hello.txt containing the word 'world'",
|
|
105
|
+
"expectations": [
|
|
106
|
+
"A file named hello.txt was created",
|
|
107
|
+
"The file contains the text 'world'"
|
|
108
|
+
]
|
|
109
|
+
}
|
|
110
|
+
]
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Permissions
|
|
115
|
+
|
|
116
|
+
**This is the most common cause of eval failures.**
|
|
117
|
+
|
|
118
|
+
skill-eval runs the agent headlessly — stdin is closed, there is no terminal. If the agent encounters a tool that requires interactive approval, it will either fail immediately or hang until the trial timeout kills it.
|
|
119
|
+
|
|
120
|
+
The runner already uses `--approval-mode auto_edit`, which auto-approves standard file operations (create, edit, delete). But if your skill needs to run shell commands, read environment variables, make network calls, or use any other tool category — those still require explicit permission.
|
|
121
|
+
|
|
122
|
+
**Solution:** place a config file inside your skill at `evals/config/<runner>/`. Before every trial, skill-eval automatically copies that directory into the agent's config location inside the isolated worktree:
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
evals/config/gemini-cli/ → <worktree>/.gemini/
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Use this to ship both settings and policies alongside your evals. For Gemini CLI, for example, you can use `settings.json` to configure tool permissions and approval policies so that every tool your skill relies on runs without prompting:
|
|
129
|
+
|
|
130
|
+
```json
|
|
131
|
+
{
|
|
132
|
+
"telemetry": { "enabled": false }
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Refer to your runner's documentation for the full list of available settings and policy keys.
|
|
137
|
+
|
|
138
|
+
> This config only applies inside the temporary worktree created for each trial. Your real workspace config is never touched.
|
|
139
|
+
|
|
140
|
+
## Try it out
|
|
141
|
+
|
|
142
|
+
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:
|
|
143
|
+
|
|
144
|
+
```sh
|
|
145
|
+
npm run test:trigger # trigger evaluation against mock-skill
|
|
146
|
+
npm run test:functional # functional evaluation against mock-skill
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Results are saved to `.project-skill-evals/runs/<timestamp>/` with logs, raw eval JSONs, and an HTML report.
|
|
150
|
+
|
|
151
|
+
## Extending
|
|
152
|
+
|
|
153
|
+
### Adding a new agent runner
|
|
154
|
+
|
|
155
|
+
1. Create `src/runners/<your-agent>/runner.ts` implementing the `AgentRunner` interface (see `src/runners/runner.interface.ts`).
|
|
156
|
+
2. Export it from `src/runners/<your-agent>/index.ts`.
|
|
157
|
+
3. Register it in `src/runners/registry.ts`:
|
|
158
|
+
```ts
|
|
159
|
+
'<your-agent>': { Runner: YourRunner, binary: '<cli-binary-name>' },
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The factory, preflight check, and CLI all pick it up automatically.
|
|
163
|
+
|
|
164
|
+
> Implement `applyRunnerConfig(evalConfigBaseDir, worktreePath)` to copy `evalConfigBaseDir/<your-agent>/` into the appropriate config directory in the worktree (e.g. `.claude/` for a Claude runner). No-op silently if the directory doesn't exist.
|
|
165
|
+
|
|
166
|
+
### Adding a new report format
|
|
167
|
+
|
|
168
|
+
1. Create `src/reporters/<format>-reporter.ts` implementing `Reporter`.
|
|
169
|
+
2. Export it and add a case in `createReporter()` in `src/reporters/index.ts`.
|
|
170
|
+
3. Add the format string to `ReportFormat` in `src/types/index.ts`.
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { EvalEnvironment } from '../core/environment.js';
|
|
4
|
+
import { Logger } from '../utils/logger.js';
|
|
5
|
+
import * as evalLoader from '../utils/eval-loader.js';
|
|
6
|
+
import { ListrEvalUI } from '../utils/ui.js';
|
|
7
|
+
import { EvalRunner } from '../core/eval-runner.js';
|
|
8
|
+
import { AgentPool } from '../core/agent-pool.js';
|
|
9
|
+
import { aggregatePassAtK, aggregateAssertionPassRate, aggregateTokenStats, aggregateDurationStats } from '../core/statistics.js';
|
|
10
|
+
import { preflight } from '../core/preflight.js';
|
|
11
|
+
import { withRetry } from '../core/trial-utils.js';
|
|
12
|
+
import { renderFunctionalTable, renderRunHeader } from '../utils/table-renderer.js';
|
|
13
|
+
import { JsonReporter } from '../reporters/index.js';
|
|
14
|
+
export async function functionalCommand(agent, workspace, skillPath, maxAgents = 4, injectedSuite, numTrials = 3, reporter = new JsonReporter(), timeoutMs, evalId) {
|
|
15
|
+
if (!injectedSuite)
|
|
16
|
+
preflight(agent, workspace, skillPath);
|
|
17
|
+
const suite = injectedSuite || evalLoader.loadEvalSuite(skillPath);
|
|
18
|
+
if (evalId !== undefined) {
|
|
19
|
+
suite.tasks = suite.tasks.filter(t => t.id === evalId);
|
|
20
|
+
if (suite.tasks.length === 0) {
|
|
21
|
+
console.error(`No eval found with id ${evalId}`);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const { skill_name, tasks } = suite;
|
|
26
|
+
const env = new EvalEnvironment({ workspace });
|
|
27
|
+
await env.setup();
|
|
28
|
+
// Ensure worktrees are cleaned up even when the process is interrupted (Ctrl+C).
|
|
29
|
+
const cleanup = () => { env.teardown().finally(() => process.exit(1)); };
|
|
30
|
+
process.once('SIGINT', cleanup);
|
|
31
|
+
process.once('SIGTERM', cleanup);
|
|
32
|
+
// Setup Artifacts Directory (Always create, even if not in debug mode, for 'show' command)
|
|
33
|
+
const debug = !!process.env.DEBUG;
|
|
34
|
+
const startTime = new Date();
|
|
35
|
+
const timestamp = startTime.toISOString().replace(/[:.]/g, '-');
|
|
36
|
+
const runDir = path.resolve(workspace, '.project-skill-evals', 'runs', timestamp);
|
|
37
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
38
|
+
const taskResults = [];
|
|
39
|
+
let withSkillTasksAllPassedCount = 0;
|
|
40
|
+
let baselineTasksAllPassedCount = 0;
|
|
41
|
+
// Per-task trial storage, indexed by task.id
|
|
42
|
+
const withoutSkillTrialsByTask = new Map();
|
|
43
|
+
const withoutSkillRunner = new EvalRunner({
|
|
44
|
+
agent,
|
|
45
|
+
workspace,
|
|
46
|
+
skillPath,
|
|
47
|
+
skillName: skill_name,
|
|
48
|
+
runDir,
|
|
49
|
+
isBaseline: true,
|
|
50
|
+
debug,
|
|
51
|
+
timeoutMs
|
|
52
|
+
});
|
|
53
|
+
const withSkillRunner = new EvalRunner({
|
|
54
|
+
agent,
|
|
55
|
+
workspace,
|
|
56
|
+
skillPath,
|
|
57
|
+
skillName: skill_name,
|
|
58
|
+
runDir,
|
|
59
|
+
isBaseline: false,
|
|
60
|
+
debug,
|
|
61
|
+
timeoutMs
|
|
62
|
+
});
|
|
63
|
+
const pool = new AgentPool(maxAgents);
|
|
64
|
+
const ui = new ListrEvalUI();
|
|
65
|
+
// Barrier chain: each prompt waits until the previous prompt's trials have all acquired slots.
|
|
66
|
+
let barrier = Promise.resolve();
|
|
67
|
+
// Subtask labels: Without Skill 1..N then With Skill 1..N for each prompt
|
|
68
|
+
const subtaskLabels = [
|
|
69
|
+
...Array.from({ length: numTrials }, (_, i) => `Without Skill ${i + 1}`),
|
|
70
|
+
...Array.from({ length: numTrials }, (_, i) => `With Skill ${i + 1}`)
|
|
71
|
+
];
|
|
72
|
+
try {
|
|
73
|
+
renderRunHeader({ command: 'functional', skillName: skill_name, agent, workspace, tasks: tasks.length, trials: numTrials, maxAgents, timeoutMs, runDir, evalId });
|
|
74
|
+
Logger.write(`──────────────────────────────────────────────────\n`);
|
|
75
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
76
|
+
const task = tasks[i];
|
|
77
|
+
const promptSnippet = `${task.prompt.substring(0, 50)}${task.prompt.length > 50 ? '...' : ''}`;
|
|
78
|
+
const taskLabel = `${promptSnippet} (#${task.id})`;
|
|
79
|
+
const thisBarrier = barrier;
|
|
80
|
+
let resolveBarrier;
|
|
81
|
+
barrier = new Promise(r => { resolveBarrier = r; });
|
|
82
|
+
ui.addTask({
|
|
83
|
+
id: task.id,
|
|
84
|
+
title: taskLabel,
|
|
85
|
+
subtaskLabels,
|
|
86
|
+
task: async (uiCtx, multi) => {
|
|
87
|
+
await thisBarrier;
|
|
88
|
+
// ── Without Skill trials (subtask IDs 1..numTrials) ──────────────
|
|
89
|
+
const baselineTrialPromises = [];
|
|
90
|
+
for (let idx = 0; idx < numTrials; idx++) {
|
|
91
|
+
const trialId = idx + 1;
|
|
92
|
+
const trialCtx = multi?.getTrialCtx(trialId) ?? uiCtx;
|
|
93
|
+
const release = await pool.acquire();
|
|
94
|
+
const p = withRetry((attempt) => withoutSkillRunner.runFunctionalTask(task, i, trialId, trialCtx, attempt)
|
|
95
|
+
.catch((error) => ({
|
|
96
|
+
id: trialId,
|
|
97
|
+
transcript: { error: error instanceof Error ? error.message : String(error) },
|
|
98
|
+
assertionResults: [{ assertion: 'Without Skill Execution', passed: false, reason: String(error) }],
|
|
99
|
+
trialPassed: false,
|
|
100
|
+
isError: true
|
|
101
|
+
})), 2, 1000, (nextAttempt, lastTrial) => {
|
|
102
|
+
const reason = lastTrial.assertionResults[0]?.reason ?? 'infrastructure error';
|
|
103
|
+
trialCtx.updateLog(`Retry ${nextAttempt}/2 — ${reason.substring(0, 50)}`);
|
|
104
|
+
}).then(trial => {
|
|
105
|
+
if (multi) {
|
|
106
|
+
const reason = trial.assertionResults.find(r => !r.passed)?.reason;
|
|
107
|
+
const passedCount = trial.assertionResults.filter(r => r.passed).length;
|
|
108
|
+
const totalCount = trial.assertionResults.length;
|
|
109
|
+
multi.markTrialComplete(trialId, trial.trialPassed, reason, trial.isError, passedCount, totalCount);
|
|
110
|
+
}
|
|
111
|
+
return trial;
|
|
112
|
+
}).finally(release);
|
|
113
|
+
baselineTrialPromises.push(p);
|
|
114
|
+
}
|
|
115
|
+
// ── With Skill trials (subtask IDs numTrials+1..2*numTrials) ─────
|
|
116
|
+
const withSkillTrialPromises = [];
|
|
117
|
+
for (let idx = 0; idx < numTrials; idx++) {
|
|
118
|
+
const subtaskId = numTrials + idx + 1; // Listr subtask position
|
|
119
|
+
const runnerTrialId = idx + 1; // 1-based trial number within WI pass
|
|
120
|
+
const trialCtx = multi?.getTrialCtx(subtaskId) ?? uiCtx;
|
|
121
|
+
const release = await pool.acquire();
|
|
122
|
+
const p = withRetry((attempt) => withSkillRunner.runFunctionalTask(task, i, runnerTrialId, trialCtx, attempt)
|
|
123
|
+
.catch((error) => ({
|
|
124
|
+
id: runnerTrialId,
|
|
125
|
+
transcript: { error: error instanceof Error ? error.message : String(error) },
|
|
126
|
+
assertionResults: [{ assertion: 'With Skill Execution', passed: false, reason: String(error) }],
|
|
127
|
+
trialPassed: false,
|
|
128
|
+
isError: true
|
|
129
|
+
})), 2, 1000, (nextAttempt, lastTrial) => {
|
|
130
|
+
const reason = lastTrial.assertionResults[0]?.reason ?? 'infrastructure error';
|
|
131
|
+
trialCtx.updateLog(`Retry ${nextAttempt}/2 — ${reason.substring(0, 50)}`);
|
|
132
|
+
}).then(trial => {
|
|
133
|
+
if (multi) {
|
|
134
|
+
const reason = trial.assertionResults.find(r => !r.passed)?.reason;
|
|
135
|
+
const passedCount = trial.assertionResults.filter(r => r.passed).length;
|
|
136
|
+
const totalCount = trial.assertionResults.length;
|
|
137
|
+
multi.markTrialComplete(subtaskId, trial.trialPassed, reason, trial.isError, passedCount, totalCount);
|
|
138
|
+
}
|
|
139
|
+
return trial;
|
|
140
|
+
}).finally(release);
|
|
141
|
+
withSkillTrialPromises.push(p);
|
|
142
|
+
}
|
|
143
|
+
// All WO + WI slots for this prompt have been acquired — signal the next prompt.
|
|
144
|
+
resolveBarrier();
|
|
145
|
+
const [woTrials, wiTrials] = await Promise.all([
|
|
146
|
+
Promise.all(baselineTrialPromises),
|
|
147
|
+
Promise.all(withSkillTrialPromises)
|
|
148
|
+
]);
|
|
149
|
+
withoutSkillTrialsByTask.set(task.id, woTrials);
|
|
150
|
+
const woPassedCount = woTrials.filter(t => t.trialPassed).length;
|
|
151
|
+
if (woPassedCount === woTrials.length)
|
|
152
|
+
baselineTasksAllPassedCount++;
|
|
153
|
+
const wiPassedCount = wiTrials.filter(t => t.trialPassed).length;
|
|
154
|
+
const score = wiTrials.length > 0 ? wiPassedCount / wiTrials.length : 0;
|
|
155
|
+
const withoutSkillTrials = withoutSkillTrialsByTask.get(task.id) ?? [];
|
|
156
|
+
const taskResult = {
|
|
157
|
+
taskId: task.id,
|
|
158
|
+
prompt: task.prompt,
|
|
159
|
+
score,
|
|
160
|
+
trials: wiTrials.map(t => ({ ...t, transcript: undefined })),
|
|
161
|
+
withoutSkillTrials: withoutSkillTrials.map(t => ({ ...t, transcript: undefined }))
|
|
162
|
+
};
|
|
163
|
+
taskResults.push(taskResult);
|
|
164
|
+
if (wiPassedCount === wiTrials.length) {
|
|
165
|
+
withSkillTasksAllPassedCount++;
|
|
166
|
+
}
|
|
167
|
+
else if (!multi) {
|
|
168
|
+
const failureReason = wiTrials.find(t => !t.trialPassed)?.assertionResults.find(r => !r.passed)?.reason || 'With Skill failed';
|
|
169
|
+
throw new Error(failureReason);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
await ui.run(tasks.length);
|
|
175
|
+
// ==== REPORTING ====
|
|
176
|
+
const { passAtK } = aggregatePassAtK(taskResults, numTrials, r => r.trials);
|
|
177
|
+
const { passAtK: withoutSkillPassAtK } = aggregatePassAtK(taskResults, numTrials, r => r.withoutSkillTrials ?? []);
|
|
178
|
+
const assertionPassRate = aggregateAssertionPassRate(taskResults, r => r.trials);
|
|
179
|
+
const withoutSkillAssertionPassRate = aggregateAssertionPassRate(taskResults, r => r.withoutSkillTrials ?? []);
|
|
180
|
+
const withSkillPercentage = Math.round(assertionPassRate * 100);
|
|
181
|
+
const withoutSkillPercentage = Math.round(withoutSkillAssertionPassRate * 100);
|
|
182
|
+
const skillUplift = withSkillPercentage - withoutSkillPercentage;
|
|
183
|
+
const withSkillTokenStats = aggregateTokenStats(taskResults.flatMap(r => r.trials)) ?? undefined;
|
|
184
|
+
const withoutSkillTokenStats = aggregateTokenStats(taskResults.flatMap(r => r.withoutSkillTrials ?? [])) ?? undefined;
|
|
185
|
+
const withSkillDurationStats = aggregateDurationStats(taskResults.flatMap(r => r.trials)) ?? undefined;
|
|
186
|
+
const withoutSkillDurationStats = aggregateDurationStats(taskResults.flatMap(r => r.withoutSkillTrials ?? [])) ?? undefined;
|
|
187
|
+
const report = {
|
|
188
|
+
timestamp: startTime.toISOString(),
|
|
189
|
+
skill_name,
|
|
190
|
+
agent,
|
|
191
|
+
metrics: {
|
|
192
|
+
withSkillScore: `${withSkillPercentage}%`,
|
|
193
|
+
withoutSkillScore: `${withoutSkillPercentage}%`,
|
|
194
|
+
skillUplift: `${skillUplift > 0 ? '+' : ''}${skillUplift}%`,
|
|
195
|
+
passedCount: withSkillTasksAllPassedCount,
|
|
196
|
+
totalCount: tasks.length,
|
|
197
|
+
numTrials,
|
|
198
|
+
passAtK: Math.round(passAtK * 1000) / 1000,
|
|
199
|
+
withoutSkillPassAtK: Math.round(withoutSkillPassAtK * 1000) / 1000,
|
|
200
|
+
assertionPassRate: Math.round(assertionPassRate * 1000) / 1000,
|
|
201
|
+
withoutSkillAssertionPassRate: Math.round(withoutSkillAssertionPassRate * 1000) / 1000,
|
|
202
|
+
tokenStats: (withSkillTokenStats || withoutSkillTokenStats)
|
|
203
|
+
? { withSkill: withSkillTokenStats, withoutSkill: withoutSkillTokenStats }
|
|
204
|
+
: undefined,
|
|
205
|
+
durationStats: (withSkillDurationStats || withoutSkillDurationStats)
|
|
206
|
+
? { withSkill: withSkillDurationStats, withoutSkill: withoutSkillDurationStats }
|
|
207
|
+
: undefined
|
|
208
|
+
},
|
|
209
|
+
results: taskResults
|
|
210
|
+
};
|
|
211
|
+
Logger.write(`\nEVALUATION SUMMARY\n`);
|
|
212
|
+
Logger.write(`──────────────────────────────────────────────────\n`);
|
|
213
|
+
renderFunctionalTable(report);
|
|
214
|
+
const upliftSign = skillUplift > 0 ? '+' : '';
|
|
215
|
+
Logger.write(`\n Skill Uplift: ${upliftSign}${skillUplift}%\n`);
|
|
216
|
+
Logger.write('\n');
|
|
217
|
+
new JsonReporter().generate(report, runDir);
|
|
218
|
+
reporter.generate(report, runDir);
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
process.off('SIGINT', cleanup);
|
|
222
|
+
process.off('SIGTERM', cleanup);
|
|
223
|
+
await env.teardown();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.rateCommand = rateCommand;
|
|
4
|
+
const logger_1 = require("../utils/logger");
|
|
5
|
+
const errors_1 = require("../core/errors");
|
|
6
|
+
async function rateCommand(score, options) {
|
|
7
|
+
const rating = parseInt(score, 10);
|
|
8
|
+
if (isNaN(rating) || rating < 1 || rating > 5) {
|
|
9
|
+
throw new errors_1.ValidationError('Rating must be a number between 1 and 5.');
|
|
10
|
+
}
|
|
11
|
+
logger_1.Logger.info('\n==========================================');
|
|
12
|
+
logger_1.Logger.info(' EXPERIENCE RATING VIEW ');
|
|
13
|
+
logger_1.Logger.info('==========================================');
|
|
14
|
+
logger_1.Logger.info(`Rating: ${'★'.repeat(rating)}${'☆'.repeat(5 - rating)}`);
|
|
15
|
+
if (options.comment) {
|
|
16
|
+
logger_1.Logger.info(`Comment: ${options.comment}`);
|
|
17
|
+
}
|
|
18
|
+
logger_1.Logger.info('==========================================');
|
|
19
|
+
logger_1.Logger.info('Thank you for your feedback!\n');
|
|
20
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { Logger } from '../utils/logger.js';
|
|
5
|
+
import { AppError } from '../core/errors.js';
|
|
6
|
+
import { renderTriggerTable, renderFunctionalTable } from '../utils/table-renderer.js';
|
|
7
|
+
import { JsonReporter } from '../core/reporters/index.js';
|
|
8
|
+
export async function showCommand(workspace, reporter = new JsonReporter()) {
|
|
9
|
+
const runsDir = path.resolve(workspace, '.project-skill-evals', 'runs');
|
|
10
|
+
if (!fs.existsSync(runsDir)) {
|
|
11
|
+
throw new AppError('No evaluation runs found. Run an evaluation first.');
|
|
12
|
+
}
|
|
13
|
+
const runs = fs.readdirSync(runsDir)
|
|
14
|
+
.filter(dir => fs.statSync(path.join(runsDir, dir)).isDirectory())
|
|
15
|
+
.sort((a, b) => b.localeCompare(a)); // Sort descending to get latest
|
|
16
|
+
if (runs.length === 0) {
|
|
17
|
+
throw new AppError('No evaluation runs found. Run an evaluation first.');
|
|
18
|
+
}
|
|
19
|
+
const latestRun = runs[0];
|
|
20
|
+
const summaryPath = path.join(runsDir, latestRun, 'summary.json');
|
|
21
|
+
if (!fs.existsSync(summaryPath)) {
|
|
22
|
+
throw new AppError(`Summary file not found for the latest run: ${latestRun}`);
|
|
23
|
+
}
|
|
24
|
+
const report = JSON.parse(fs.readFileSync(summaryPath, 'utf-8'));
|
|
25
|
+
const { skill_name, agent, metrics, results, timestamp } = report;
|
|
26
|
+
const numTrials = metrics.numTrials || 1;
|
|
27
|
+
const isFunctional = metrics.withoutSkillScore !== undefined;
|
|
28
|
+
Logger.write(`\n${chalk.bold('LATEST EVALUATION RESULTS')}\n`);
|
|
29
|
+
Logger.write(`Timestamp: ${new Date(timestamp).toLocaleString()}\n`);
|
|
30
|
+
Logger.write(`Skill: ${skill_name}\n`);
|
|
31
|
+
Logger.write(`Agent: ${agent}\n`);
|
|
32
|
+
Logger.write(`Type: ${isFunctional ? 'Functional' : 'Trigger'}\n`);
|
|
33
|
+
Logger.write(`──────────────────────────────────────────────────\n`);
|
|
34
|
+
if (isFunctional) {
|
|
35
|
+
renderFunctionalTable(report);
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
renderTriggerTable(report);
|
|
39
|
+
}
|
|
40
|
+
Logger.write('\n');
|
|
41
|
+
const runDir = path.join(runsDir, latestRun);
|
|
42
|
+
reporter.generate(report, runDir);
|
|
43
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { EvalEnvironment } from '../core/environment.js';
|
|
4
|
+
import { Logger } from '../utils/logger.js';
|
|
5
|
+
import * as evalLoader from '../utils/eval-loader.js';
|
|
6
|
+
import { ListrEvalUI } from '../utils/ui.js';
|
|
7
|
+
import { EvalRunner } from '../core/eval-runner.js';
|
|
8
|
+
import { AgentPool } from '../core/agent-pool.js';
|
|
9
|
+
import { aggregatePassAtK, aggregateTokenStats, aggregateDurationStats } from '../core/statistics.js';
|
|
10
|
+
import { preflight } from '../core/preflight.js';
|
|
11
|
+
import { withRetry } from '../core/trial-utils.js';
|
|
12
|
+
import { renderTriggerTable, renderRunHeader } from '../utils/table-renderer.js';
|
|
13
|
+
import { JsonReporter } from '../reporters/index.js';
|
|
14
|
+
export async function triggerCommand(agent, workspace, skillPath, maxAgents = 4, injectedSuite, numTrials = 3, reporter = new JsonReporter(), timeoutMs, evalId) {
|
|
15
|
+
if (!injectedSuite)
|
|
16
|
+
preflight(agent, workspace, skillPath);
|
|
17
|
+
const suite = injectedSuite || evalLoader.loadEvalSuite(skillPath);
|
|
18
|
+
if (evalId !== undefined) {
|
|
19
|
+
suite.tasks = suite.tasks.filter(t => t.id === evalId);
|
|
20
|
+
if (suite.tasks.length === 0) {
|
|
21
|
+
console.error(`No eval found with id ${evalId}`);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const { skill_name, tasks } = suite;
|
|
26
|
+
// Setup Environment (global setup)
|
|
27
|
+
const env = new EvalEnvironment({ workspace });
|
|
28
|
+
await env.setup();
|
|
29
|
+
// Ensure worktrees are cleaned up even when the process is interrupted (Ctrl+C).
|
|
30
|
+
const cleanup = () => { env.teardown().finally(() => process.exit(1)); };
|
|
31
|
+
process.once('SIGINT', cleanup);
|
|
32
|
+
process.once('SIGTERM', cleanup);
|
|
33
|
+
// Setup Artifacts Directory (Always create, even if not in debug mode, for 'show' command)
|
|
34
|
+
const debug = !!process.env.DEBUG;
|
|
35
|
+
const startTime = new Date();
|
|
36
|
+
const timestamp = startTime.toISOString().replace(/[:.]/g, '-');
|
|
37
|
+
const runDir = path.resolve(workspace, '.project-skill-evals', 'runs', timestamp);
|
|
38
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
39
|
+
const taskResults = [];
|
|
40
|
+
let tasksPassedCount = 0;
|
|
41
|
+
const runner = new EvalRunner({
|
|
42
|
+
agent,
|
|
43
|
+
workspace,
|
|
44
|
+
skillPath,
|
|
45
|
+
skillName: skill_name,
|
|
46
|
+
runDir,
|
|
47
|
+
debug,
|
|
48
|
+
timeoutMs
|
|
49
|
+
});
|
|
50
|
+
const pool = new AgentPool(maxAgents);
|
|
51
|
+
const ui = new ListrEvalUI();
|
|
52
|
+
// Barrier chain: each prompt waits until the previous prompt's trials have all acquired slots.
|
|
53
|
+
let barrier = Promise.resolve();
|
|
54
|
+
try {
|
|
55
|
+
renderRunHeader({ command: 'trigger', skillName: skill_name, agent, workspace, tasks: tasks.length, trials: numTrials, maxAgents, timeoutMs, runDir, evalId });
|
|
56
|
+
Logger.write(`--- Trigger Pass ---\n`);
|
|
57
|
+
Logger.write(`──────────────────────────────────────────────────\n`);
|
|
58
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
59
|
+
const task = tasks[i];
|
|
60
|
+
const promptSnippet = `${task.prompt.substring(0, 50)}${task.prompt.length > 50 ? '...' : ''}`;
|
|
61
|
+
const taskLabel = `${promptSnippet} (#${task.id})`;
|
|
62
|
+
const thisBarrier = barrier;
|
|
63
|
+
let resolveBarrier;
|
|
64
|
+
barrier = new Promise(r => { resolveBarrier = r; });
|
|
65
|
+
ui.addTask({
|
|
66
|
+
id: task.id,
|
|
67
|
+
title: taskLabel,
|
|
68
|
+
numTrials,
|
|
69
|
+
task: async (uiCtx, multi) => {
|
|
70
|
+
await thisBarrier;
|
|
71
|
+
const trialPromises = [];
|
|
72
|
+
for (let idx = 0; idx < numTrials; idx++) {
|
|
73
|
+
const trialId = idx + 1;
|
|
74
|
+
const trialCtx = multi?.getTrialCtx(trialId) ?? uiCtx;
|
|
75
|
+
const release = await pool.acquire();
|
|
76
|
+
const p = withRetry((attempt) => runner.runTriggerTask(task, i, trialId, trialCtx, attempt)
|
|
77
|
+
.catch((error) => ({
|
|
78
|
+
id: trialId,
|
|
79
|
+
transcript: { error: error instanceof Error ? error.message : String(error) },
|
|
80
|
+
assertionResults: [{
|
|
81
|
+
assertion: 'Runner Execution',
|
|
82
|
+
passed: false,
|
|
83
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
84
|
+
}],
|
|
85
|
+
trialPassed: false,
|
|
86
|
+
isError: true
|
|
87
|
+
})), 2, 1000, (nextAttempt, lastTrial) => {
|
|
88
|
+
const reason = lastTrial.assertionResults[0]?.reason ?? 'infrastructure error';
|
|
89
|
+
trialCtx.updateLog(`Retry ${nextAttempt}/2 — ${reason.substring(0, 50)}`);
|
|
90
|
+
}).then(trial => {
|
|
91
|
+
if (multi) {
|
|
92
|
+
const reason = trial.assertionResults.find(r => !r.passed)?.reason;
|
|
93
|
+
multi.markTrialComplete(trialId, trial.trialPassed, reason, trial.isError);
|
|
94
|
+
}
|
|
95
|
+
return trial;
|
|
96
|
+
}).finally(release);
|
|
97
|
+
trialPromises.push(p);
|
|
98
|
+
}
|
|
99
|
+
// All trials for this prompt have acquired a slot — signal the next prompt.
|
|
100
|
+
resolveBarrier();
|
|
101
|
+
const trials = await Promise.all(trialPromises);
|
|
102
|
+
const passedCount = trials.filter(t => t.trialPassed).length;
|
|
103
|
+
const score = trials.length > 0 ? passedCount / trials.length : 0;
|
|
104
|
+
const taskResult = {
|
|
105
|
+
taskId: task.id,
|
|
106
|
+
prompt: task.prompt,
|
|
107
|
+
score,
|
|
108
|
+
trials: trials.map(t => ({ ...t, transcript: undefined }))
|
|
109
|
+
};
|
|
110
|
+
taskResults.push(taskResult);
|
|
111
|
+
if (passedCount === trials.length) {
|
|
112
|
+
tasksPassedCount++;
|
|
113
|
+
}
|
|
114
|
+
else if (!multi) {
|
|
115
|
+
const failureReason = trials.find(t => !t.trialPassed)?.assertionResults.find(r => !r.passed)?.reason || 'Task failed evaluation';
|
|
116
|
+
throw new Error(failureReason);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
await ui.run(tasks.length);
|
|
122
|
+
// Compute aggregate metrics and build report
|
|
123
|
+
const { passAtK } = aggregatePassAtK(taskResults, numTrials, r => r.trials);
|
|
124
|
+
const percentage = Math.round(passAtK * 100);
|
|
125
|
+
const withSkillTokenStats = aggregateTokenStats(taskResults.flatMap(r => r.trials)) ?? undefined;
|
|
126
|
+
const withSkillDurationStats = aggregateDurationStats(taskResults.flatMap(r => r.trials)) ?? undefined;
|
|
127
|
+
const report = {
|
|
128
|
+
timestamp: startTime.toISOString(),
|
|
129
|
+
skill_name,
|
|
130
|
+
agent,
|
|
131
|
+
metrics: {
|
|
132
|
+
withSkillScore: `${percentage}%`,
|
|
133
|
+
passedCount: tasksPassedCount,
|
|
134
|
+
totalCount: tasks.length,
|
|
135
|
+
numTrials,
|
|
136
|
+
passAtK: Math.round(passAtK * 1000) / 1000,
|
|
137
|
+
tokenStats: withSkillTokenStats ? { withSkill: withSkillTokenStats } : undefined,
|
|
138
|
+
durationStats: withSkillDurationStats ? { withSkill: withSkillDurationStats } : undefined
|
|
139
|
+
},
|
|
140
|
+
results: taskResults
|
|
141
|
+
};
|
|
142
|
+
Logger.write(`\nEVALUATION SUMMARY\n`);
|
|
143
|
+
Logger.write(`──────────────────────────────────────────────────\n`);
|
|
144
|
+
renderTriggerTable(report);
|
|
145
|
+
Logger.write('\n\n');
|
|
146
|
+
new JsonReporter().generate(report, runDir);
|
|
147
|
+
reporter.generate(report, runDir);
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
process.off('SIGINT', cleanup);
|
|
151
|
+
process.off('SIGTERM', cleanup);
|
|
152
|
+
await env.teardown();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.viewCommand = viewCommand;
|
|
4
|
+
const logger_1 = require("../utils/logger");
|
|
5
|
+
async function viewCommand(options) {
|
|
6
|
+
const ease = parseInt(options.ease || '0', 10);
|
|
7
|
+
const speed = parseInt(options.speed || '0', 10);
|
|
8
|
+
const accuracy = parseInt(options.accuracy || '0', 10);
|
|
9
|
+
const drawStars = (score) => {
|
|
10
|
+
const stars = '★'.repeat(score);
|
|
11
|
+
const empty = '☆'.repeat(5 - score);
|
|
12
|
+
return `[ ${stars}${empty} ]`;
|
|
13
|
+
};
|
|
14
|
+
logger_1.Logger.info('\n┌──────────────────────────────────────────┐');
|
|
15
|
+
logger_1.Logger.info('│ NUEVA VISTA DE CALIFICACIÓN │');
|
|
16
|
+
logger_1.Logger.info('├──────────────────────────────────────────┤');
|
|
17
|
+
logger_1.Logger.info(`│ Facilidad de uso: ${drawStars(ease)} │`);
|
|
18
|
+
logger_1.Logger.info(`│ Velocidad: ${drawStars(speed)} │`);
|
|
19
|
+
logger_1.Logger.info(`│ Precisión: ${drawStars(accuracy)} │`);
|
|
20
|
+
logger_1.Logger.info('├──────────────────────────────────────────┤');
|
|
21
|
+
if (options.comment) {
|
|
22
|
+
logger_1.Logger.info(`│ Comentario: │`);
|
|
23
|
+
// Basic wrapping for the comment
|
|
24
|
+
const comment = options.comment.substring(0, 38);
|
|
25
|
+
logger_1.Logger.info(`│ ${comment.padEnd(40)} │`);
|
|
26
|
+
}
|
|
27
|
+
logger_1.Logger.info('├──────────────────────────────────────────┤');
|
|
28
|
+
logger_1.Logger.info('│ ¡Gracias por calificar tu experiencia! │');
|
|
29
|
+
logger_1.Logger.info('└──────────────────────────────────────────┘\n');
|
|
30
|
+
}
|