@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
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { ConfigError } from '../core/errors.js';
|
|
4
|
+
/**
|
|
5
|
+
* Loads and merges all JSON evaluation files from a skill's evals directory.
|
|
6
|
+
* Aligned with Anthropic's recommendation to split evals by capability/regression.
|
|
7
|
+
* Supports legacy 'tasks' and 'assertions' internally while maintaining 'evals' and 'expectations' in files.
|
|
8
|
+
*/
|
|
9
|
+
export function loadEvalSuite(skillPath) {
|
|
10
|
+
const evalsDir = path.resolve(skillPath, 'evals');
|
|
11
|
+
if (!fs.existsSync(evalsDir)) {
|
|
12
|
+
throw new ConfigError(`Could not find evals directory at ${evalsDir}`);
|
|
13
|
+
}
|
|
14
|
+
const files = fs.readdirSync(evalsDir).filter(file => file.endsWith('.json'));
|
|
15
|
+
if (files.length === 0) {
|
|
16
|
+
throw new ConfigError(`No JSON evaluation files found in ${evalsDir}`);
|
|
17
|
+
}
|
|
18
|
+
let mergedSkillName = '';
|
|
19
|
+
const mergedTasks = [];
|
|
20
|
+
for (const file of files) {
|
|
21
|
+
const filePath = path.join(evalsDir, file);
|
|
22
|
+
let config;
|
|
23
|
+
try {
|
|
24
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
25
|
+
config = JSON.parse(raw);
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
throw new ConfigError(`Failed to parse ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
29
|
+
}
|
|
30
|
+
const skill_name = config.skill_name;
|
|
31
|
+
// Standard input uses 'evals' key
|
|
32
|
+
const rawEvals = config.evals || config.tasks;
|
|
33
|
+
if (!skill_name || !Array.isArray(rawEvals)) {
|
|
34
|
+
throw new ConfigError(`Invalid format in ${file}. Expected 'skill_name' and 'evals' array.`);
|
|
35
|
+
}
|
|
36
|
+
if (!mergedSkillName) {
|
|
37
|
+
mergedSkillName = skill_name;
|
|
38
|
+
}
|
|
39
|
+
else if (mergedSkillName !== skill_name) {
|
|
40
|
+
throw new ConfigError(`Skill name mismatch in ${file}. Expected '${mergedSkillName}' but found '${skill_name}'.`);
|
|
41
|
+
}
|
|
42
|
+
// Map input fields to internal terminology
|
|
43
|
+
const mappedTasks = rawEvals.map((e) => {
|
|
44
|
+
if (e.id === undefined || typeof e.id !== 'number') {
|
|
45
|
+
throw new ConfigError(`Invalid task ID in ${file}. ID must be a number.`);
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
id: e.id,
|
|
49
|
+
prompt: e.prompt,
|
|
50
|
+
expected_output: e.expected_output,
|
|
51
|
+
assertions: e.expectations || e.assertions,
|
|
52
|
+
files: e.files
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
mergedTasks.push(...mappedTasks);
|
|
56
|
+
}
|
|
57
|
+
if (mergedTasks.length === 0) {
|
|
58
|
+
throw new ConfigError(`No evaluations found in any of the JSON files in ${evalsDir}`);
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
skill_name: mergedSkillName,
|
|
62
|
+
tasks: mergedTasks
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
// Backwards compatibility alias
|
|
66
|
+
export const loadEvals = loadEvalSuite;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import Table from 'cli-table3';
|
|
4
|
+
export class Logger {
|
|
5
|
+
static info(message) {
|
|
6
|
+
console.log(chalk.blue('ℹ'), message);
|
|
7
|
+
}
|
|
8
|
+
static error(message, error) {
|
|
9
|
+
console.error(chalk.red('✖'), chalk.red(message), error || '');
|
|
10
|
+
}
|
|
11
|
+
static warn(message) {
|
|
12
|
+
console.warn(chalk.yellow('⚠'), chalk.yellow(message));
|
|
13
|
+
}
|
|
14
|
+
static success(message) {
|
|
15
|
+
console.log(chalk.green('✔'), chalk.green(message));
|
|
16
|
+
}
|
|
17
|
+
static debug(message) {
|
|
18
|
+
if (process.env.DEBUG) {
|
|
19
|
+
console.log(chalk.gray('[Debug]'), message);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
static trace(error) {
|
|
23
|
+
if (typeof error === 'string') {
|
|
24
|
+
console.error(chalk.red('Trace:'), error);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
console.error(chalk.red.bold(`\nTrace: ${error.message}`));
|
|
28
|
+
if (error.stack) {
|
|
29
|
+
const stack = error.stack
|
|
30
|
+
.split('\n')
|
|
31
|
+
.slice(1)
|
|
32
|
+
.map(line => chalk.gray(line))
|
|
33
|
+
.join('\n');
|
|
34
|
+
console.error(stack);
|
|
35
|
+
}
|
|
36
|
+
console.error('');
|
|
37
|
+
}
|
|
38
|
+
static write(message) {
|
|
39
|
+
process.stdout.write(message);
|
|
40
|
+
}
|
|
41
|
+
static table(data, options = {}) {
|
|
42
|
+
const table = new Table({
|
|
43
|
+
chars: {
|
|
44
|
+
'top': '─', 'top-mid': '┬', 'top-left': '┌', 'top-right': '┐',
|
|
45
|
+
'bottom': '─', 'bottom-mid': '┴', 'bottom-left': '└', 'bottom-right': '┘',
|
|
46
|
+
'left': '│', 'left-mid': '├', 'mid': '─', 'mid-mid': '┼',
|
|
47
|
+
'right': '│', 'right-mid': '┤', 'middle': '│'
|
|
48
|
+
},
|
|
49
|
+
style: { head: ['cyan'], border: ['gray'] },
|
|
50
|
+
...options
|
|
51
|
+
});
|
|
52
|
+
table.push(...data);
|
|
53
|
+
console.log(table.toString());
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export class Spinner {
|
|
57
|
+
spinner;
|
|
58
|
+
constructor(prefix = ' Running agent') {
|
|
59
|
+
this.spinner = ora({
|
|
60
|
+
text: prefix,
|
|
61
|
+
color: 'cyan',
|
|
62
|
+
spinner: 'dots'
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
start() {
|
|
66
|
+
this.spinner.start();
|
|
67
|
+
}
|
|
68
|
+
updateLog(log) {
|
|
69
|
+
// Sanitize log to single line and limit length
|
|
70
|
+
const sanitized = log.replace(/\n/g, ' ').trim();
|
|
71
|
+
const truncated = sanitized.length > 60 ? sanitized.substring(0, 57) + '...' : sanitized;
|
|
72
|
+
this.spinner.text = `${this.spinner.text.split(' [')[0]} [${truncated}]`;
|
|
73
|
+
}
|
|
74
|
+
stop(finalMessage = 'Done.') {
|
|
75
|
+
this.spinner.succeed(finalMessage);
|
|
76
|
+
}
|
|
77
|
+
stopAndClear() {
|
|
78
|
+
this.spinner.stop();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses Newline-Delimited JSON (NDJSON) output into an array of events.
|
|
3
|
+
* Each non-empty line is parsed as a complete JSON value.
|
|
4
|
+
* Lines that are not valid JSON (e.g. ANSI codes, status text) are skipped silently.
|
|
5
|
+
* Unknown event types (not in NdjsonEvent union) are cast and silently ignored by callers.
|
|
6
|
+
*/
|
|
7
|
+
export function parseNdjsonEvents(output) {
|
|
8
|
+
const events = [];
|
|
9
|
+
for (const line of output.split('\n')) {
|
|
10
|
+
const trimmed = line.trim();
|
|
11
|
+
if (!trimmed)
|
|
12
|
+
continue;
|
|
13
|
+
try {
|
|
14
|
+
events.push(JSON.parse(trimmed));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// Non-JSON line — skip silently
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return events;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Parses a Gemini CLI stream-json stdout blob into a clean result.
|
|
24
|
+
* Returns { error } if the result event signals failure.
|
|
25
|
+
* Returns { response } with joined assistant text on success.
|
|
26
|
+
* Returns null if no result event is present (non-stream output).
|
|
27
|
+
*/
|
|
28
|
+
export function parseStreamResult(output) {
|
|
29
|
+
let deltaBuffer = '';
|
|
30
|
+
const completedParts = [];
|
|
31
|
+
let resultEvent = null;
|
|
32
|
+
for (const event of parseNdjsonEvents(output)) {
|
|
33
|
+
if (event.type === 'message' && event.role === 'assistant' && typeof event.content === 'string') {
|
|
34
|
+
if (event.delta) {
|
|
35
|
+
// Streaming fragment — concatenate directly, no separator
|
|
36
|
+
deltaBuffer += event.content;
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
// Complete message turn — flush delta buffer first, then add as a separate turn
|
|
40
|
+
if (deltaBuffer) {
|
|
41
|
+
completedParts.push(deltaBuffer);
|
|
42
|
+
deltaBuffer = '';
|
|
43
|
+
}
|
|
44
|
+
completedParts.push(event.content);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
else if (event.type === 'result') {
|
|
48
|
+
resultEvent = event;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Flush any trailing delta fragments
|
|
52
|
+
if (deltaBuffer) {
|
|
53
|
+
completedParts.push(deltaBuffer);
|
|
54
|
+
}
|
|
55
|
+
if (!resultEvent)
|
|
56
|
+
return null;
|
|
57
|
+
if (resultEvent.status === 'error') {
|
|
58
|
+
const msg = resultEvent.error?.message || 'Agent run failed';
|
|
59
|
+
return { error: msg };
|
|
60
|
+
}
|
|
61
|
+
const text = completedParts.join('\n').trim() ||
|
|
62
|
+
(typeof resultEvent.response === 'string' ? resultEvent.response : '');
|
|
63
|
+
return { response: text };
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Extracts token consumption stats from a Gemini CLI stream-json stdout blob.
|
|
67
|
+
* Looks for a result event with a stats.total_tokens field.
|
|
68
|
+
* Returns null if no such event is found or stats are absent.
|
|
69
|
+
*/
|
|
70
|
+
export function parseTokenStats(output) {
|
|
71
|
+
for (const event of parseNdjsonEvents(output)) {
|
|
72
|
+
if (event.type === 'result' && event.stats) {
|
|
73
|
+
const s = event.stats;
|
|
74
|
+
if (typeof s.total_tokens === 'number') {
|
|
75
|
+
return {
|
|
76
|
+
totalTokens: s.total_tokens,
|
|
77
|
+
inputTokens: typeof s.input_tokens === 'number' ? s.input_tokens : 0,
|
|
78
|
+
outputTokens: typeof s.output_tokens === 'number' ? s.output_tokens : 0,
|
|
79
|
+
cachedTokens: typeof s.cached === 'number' ? s.cached : 0,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { Logger } from './logger.js';
|
|
4
|
+
import { computePassAtK } from '../core/statistics.js';
|
|
5
|
+
/**
|
|
6
|
+
* Returns a color-coded assertion pass rate string for a set of trials.
|
|
7
|
+
* Only non-error trials contribute assertions to the rate.
|
|
8
|
+
* Color thresholds: ≥80% green, ≥50% yellow, <50% red.
|
|
9
|
+
*/
|
|
10
|
+
function formatAssertionRate(trials) {
|
|
11
|
+
if (trials.length === 0)
|
|
12
|
+
return chalk.gray('—');
|
|
13
|
+
const allError = trials.every(t => t.isError);
|
|
14
|
+
const someError = trials.some(t => t.isError);
|
|
15
|
+
const relevant = trials.filter(t => !t.isError);
|
|
16
|
+
const total = relevant.reduce((s, t) => s + t.assertionResults.length, 0);
|
|
17
|
+
const passed = relevant.reduce((s, t) => s + t.assertionResults.filter(r => r.passed).length, 0);
|
|
18
|
+
const pct = total > 0 ? Math.round((passed / total) * 100) : 0;
|
|
19
|
+
if (allError)
|
|
20
|
+
return chalk.yellow('Error');
|
|
21
|
+
if (someError)
|
|
22
|
+
return chalk.yellow(`${pct}%*`);
|
|
23
|
+
if (pct >= 80)
|
|
24
|
+
return chalk.green(`${pct}%`);
|
|
25
|
+
if (pct >= 50)
|
|
26
|
+
return chalk.yellow(`${pct}%`);
|
|
27
|
+
return chalk.red(`${pct}%`);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Formats a duration in milliseconds for human-readable display.
|
|
31
|
+
* e.g. 45000 → "45s", 90000 → "1m 30s"
|
|
32
|
+
*/
|
|
33
|
+
export function formatDuration(ms) {
|
|
34
|
+
if (ms < 1000)
|
|
35
|
+
return `${ms}ms`;
|
|
36
|
+
const s = Math.round(ms / 1000);
|
|
37
|
+
if (s < 60)
|
|
38
|
+
return `${s}s`;
|
|
39
|
+
const m = Math.floor(s / 60);
|
|
40
|
+
const rem = s % 60;
|
|
41
|
+
return rem > 0 ? `${m}m ${rem}s` : `${m}m`;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Formats a token count for human-readable display.
|
|
45
|
+
* Numbers >= 1M are shown as "1.2M", >= 1K as "119K", else as-is.
|
|
46
|
+
*/
|
|
47
|
+
export function formatTokens(n) {
|
|
48
|
+
if (n >= 1_000_000)
|
|
49
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
50
|
+
if (n >= 1_000)
|
|
51
|
+
return `${Math.round(n / 1_000)}K`;
|
|
52
|
+
return `${n}`;
|
|
53
|
+
}
|
|
54
|
+
function formatTokenStatsLine(stats) {
|
|
55
|
+
const total = formatTokens(stats.avgTotal);
|
|
56
|
+
const input = formatTokens(stats.avgInput);
|
|
57
|
+
const output = formatTokens(stats.avgOutput);
|
|
58
|
+
const cached = formatTokens(stats.avgCached);
|
|
59
|
+
return `${total} total (${input} input + ${output} output, ${cached} cached)`;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Returns a color-coded pass@1 string for a set of trials:
|
|
63
|
+
* - All errored → yellow "Error"
|
|
64
|
+
* - Some errored → yellow "X%*" (unreliable, partial measurement)
|
|
65
|
+
* - None errored → green "X%" (reliable measurement)
|
|
66
|
+
*/
|
|
67
|
+
function formatPassAt1(trials) {
|
|
68
|
+
const allError = trials.length > 0 && trials.every(t => t.isError);
|
|
69
|
+
const someError = trials.some(t => t.isError);
|
|
70
|
+
const p1 = Math.round(computePassAtK(trials, 1) * 100);
|
|
71
|
+
if (allError)
|
|
72
|
+
return chalk.yellow('Error');
|
|
73
|
+
if (someError)
|
|
74
|
+
return chalk.yellow(`${p1}%*`);
|
|
75
|
+
return chalk.green(`${p1}%`);
|
|
76
|
+
}
|
|
77
|
+
const BOX_INNER = 56; // visible chars between │ and │ (one space padding each side)
|
|
78
|
+
function stripAnsi(s) {
|
|
79
|
+
return s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
80
|
+
}
|
|
81
|
+
function boxLine(content = '') {
|
|
82
|
+
const visible = stripAnsi(content).length;
|
|
83
|
+
const pad = Math.max(0, BOX_INNER - visible);
|
|
84
|
+
return chalk.gray('│') + ' ' + content + ' '.repeat(pad) + ' ' + chalk.gray('│');
|
|
85
|
+
}
|
|
86
|
+
function boxLabel(key, value) {
|
|
87
|
+
const keyPart = chalk.gray(key.padEnd(11));
|
|
88
|
+
return boxLine(keyPart + ' ' + chalk.white(value));
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Renders a styled run-config header before the evaluation UI starts.
|
|
92
|
+
* Always shown regardless of debug mode.
|
|
93
|
+
*/
|
|
94
|
+
export function renderRunHeader(config) {
|
|
95
|
+
const { command, skillName, agent, workspace, tasks, trials, maxAgents, timeoutMs, runDir, evalId } = config;
|
|
96
|
+
let timeoutStr = 'None';
|
|
97
|
+
if (timeoutMs && timeoutMs > 0) {
|
|
98
|
+
const timeoutSec = timeoutMs / 1000;
|
|
99
|
+
timeoutStr = timeoutSec % 60 === 0 ? `${timeoutSec / 60}m` : `${timeoutSec}s`;
|
|
100
|
+
}
|
|
101
|
+
const relRunDir = path.relative(workspace, runDir);
|
|
102
|
+
const maxOutputLen = BOX_INNER - 13; // 11 label + 2 spaces
|
|
103
|
+
const outputStr = relRunDir.length > maxOutputLen ? relRunDir.slice(0, maxOutputLen - 1) + '…' : relRunDir;
|
|
104
|
+
const titleLabel = 'skill-eval';
|
|
105
|
+
const dashes = '─'.repeat(BOX_INNER - titleLabel.length);
|
|
106
|
+
const top = chalk.gray('┌─ ') + chalk.bold(titleLabel) + ' ' + chalk.gray(dashes + '┐');
|
|
107
|
+
const bottom = chalk.gray('└' + '─'.repeat(BOX_INNER + 2) + '┘');
|
|
108
|
+
const commandPart = evalId !== undefined ? `${command} · eval #${evalId}` : command;
|
|
109
|
+
const title = chalk.bold.cyan(skillName) + chalk.gray(` · ${commandPart}`);
|
|
110
|
+
const runLine = `${tasks} task${tasks !== 1 ? 's' : ''} · ${trials} trial${trials !== 1 ? 's' : ''} · agents ${maxAgents}`;
|
|
111
|
+
process.stdout.write('\n');
|
|
112
|
+
process.stdout.write(top + '\n');
|
|
113
|
+
process.stdout.write(boxLine(title) + '\n');
|
|
114
|
+
process.stdout.write(boxLine() + '\n');
|
|
115
|
+
process.stdout.write(boxLabel('agent', agent) + '\n');
|
|
116
|
+
process.stdout.write(boxLabel('run', runLine) + '\n');
|
|
117
|
+
process.stdout.write(boxLabel('timeout', timeoutStr) + '\n');
|
|
118
|
+
process.stdout.write(boxLabel('output', outputStr) + '\n');
|
|
119
|
+
process.stdout.write(bottom + '\n\n');
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Renders a trigger evaluation summary table and rate line to the terminal.
|
|
123
|
+
* Accepts a full EvalSuiteReport so it can be called from both live commands
|
|
124
|
+
* and the `show` command (which reads from disk).
|
|
125
|
+
*/
|
|
126
|
+
export function renderTriggerTable(report) {
|
|
127
|
+
const { results, metrics } = report;
|
|
128
|
+
const numTrials = metrics.numTrials || 1;
|
|
129
|
+
const tableData = numTrials > 1
|
|
130
|
+
? [['ID', 'Prompt', 'Trials', 'success rate']]
|
|
131
|
+
: [['ID', 'Prompt', 'success rate']];
|
|
132
|
+
let hasPartialErrors = false;
|
|
133
|
+
for (const result of results) {
|
|
134
|
+
const promptSnippet = result.prompt.substring(0, 40) + (result.prompt.length > 40 ? '...' : '');
|
|
135
|
+
const p1Cell = formatPassAt1(result.trials);
|
|
136
|
+
const someError = result.trials.some(t => t.isError);
|
|
137
|
+
const allError = result.trials.length > 0 && result.trials.every(t => t.isError);
|
|
138
|
+
if (someError && !allError)
|
|
139
|
+
hasPartialErrors = true;
|
|
140
|
+
if (numTrials > 1) {
|
|
141
|
+
const errorCount = result.trials.filter(t => t.isError).length;
|
|
142
|
+
const passedCount = result.trials.filter(t => t.trialPassed).length;
|
|
143
|
+
const trialsBase = `${passedCount}/${result.trials.length}`;
|
|
144
|
+
const trialsStr = errorCount > 0 ? `${trialsBase} (${errorCount}!)` : trialsBase;
|
|
145
|
+
const trials = result.score === 1.0 ? chalk.green(trialsStr) : errorCount > 0 ? chalk.yellow(trialsStr) : chalk.red(trialsStr);
|
|
146
|
+
tableData.push([result.taskId.toString(), promptSnippet, trials, p1Cell]);
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
tableData.push([result.taskId.toString(), promptSnippet, p1Cell]);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
Logger.table(tableData);
|
|
153
|
+
if (hasPartialErrors) {
|
|
154
|
+
Logger.write(chalk.yellow('\n * Some trials did not complete due to infrastructure errors. success rate is computed over the trials that ran.'));
|
|
155
|
+
}
|
|
156
|
+
const percentage = Math.round((metrics.passAtK || 0) * 100);
|
|
157
|
+
Logger.write(`\n Trigger Success Rate: ${percentage}%`);
|
|
158
|
+
const withSkillTokenStats = metrics.tokenStats?.withSkill;
|
|
159
|
+
if (withSkillTokenStats) {
|
|
160
|
+
Logger.write(`\n Avg Tokens: ${formatTokenStatsLine(withSkillTokenStats)}`);
|
|
161
|
+
}
|
|
162
|
+
const withSkillDurationStats = metrics.durationStats?.withSkill;
|
|
163
|
+
if (withSkillDurationStats) {
|
|
164
|
+
Logger.write(`\n Avg Time: ${formatDuration(withSkillDurationStats.avgMs)}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Renders a functional evaluation summary table and rate lines to the terminal.
|
|
169
|
+
*/
|
|
170
|
+
export function renderFunctionalTable(report) {
|
|
171
|
+
const { results, metrics } = report;
|
|
172
|
+
const tableData = [['ID', 'Prompt', 'Without Skill', 'With Skill']];
|
|
173
|
+
let hasPartialErrors = false;
|
|
174
|
+
for (const result of results) {
|
|
175
|
+
const promptSnippet = result.prompt.substring(0, 40) + (result.prompt.length > 40 ? '...' : '');
|
|
176
|
+
const withoutSkillTrials = result.withoutSkillTrials || [];
|
|
177
|
+
const withSkillTrials = result.trials;
|
|
178
|
+
const baselineSomeError = withoutSkillTrials.some(t => t.isError);
|
|
179
|
+
const baselineAllError = withoutSkillTrials.length > 0 && withoutSkillTrials.every(t => t.isError);
|
|
180
|
+
const withSkillSomeError = withSkillTrials.some(t => t.isError);
|
|
181
|
+
const withSkillAllError = withSkillTrials.length > 0 && withSkillTrials.every(t => t.isError);
|
|
182
|
+
if ((baselineSomeError && !baselineAllError) || (withSkillSomeError && !withSkillAllError))
|
|
183
|
+
hasPartialErrors = true;
|
|
184
|
+
tableData.push([
|
|
185
|
+
result.taskId.toString(),
|
|
186
|
+
promptSnippet,
|
|
187
|
+
formatAssertionRate(withoutSkillTrials),
|
|
188
|
+
formatAssertionRate(withSkillTrials),
|
|
189
|
+
]);
|
|
190
|
+
}
|
|
191
|
+
Logger.table(tableData);
|
|
192
|
+
if (hasPartialErrors) {
|
|
193
|
+
Logger.write(chalk.yellow('\n * Some trials did not complete due to infrastructure errors. success rate is computed over the trials that ran.'));
|
|
194
|
+
}
|
|
195
|
+
const withoutSkillPercentage = Math.round(((metrics.withoutSkillAssertionPassRate ?? metrics.withoutSkillPassAtK) || 0) * 100);
|
|
196
|
+
const withSkillPercentage = Math.round(((metrics.assertionPassRate ?? metrics.passAtK) || 0) * 100);
|
|
197
|
+
Logger.write(`\n Without Skill Rate: ${withoutSkillPercentage}%`);
|
|
198
|
+
Logger.write(`\n With Skill Rate: ${withSkillPercentage}%`);
|
|
199
|
+
const baselineTokenStats = metrics.tokenStats?.withoutSkill;
|
|
200
|
+
const withSkillTokenStats = metrics.tokenStats?.withSkill;
|
|
201
|
+
if (baselineTokenStats || withSkillTokenStats) {
|
|
202
|
+
if (baselineTokenStats)
|
|
203
|
+
Logger.write(`\n Tokens (w/o skill): ${formatTokenStatsLine(baselineTokenStats)}`);
|
|
204
|
+
if (withSkillTokenStats)
|
|
205
|
+
Logger.write(`\n Tokens (w/ skill): ${formatTokenStatsLine(withSkillTokenStats)}`);
|
|
206
|
+
if (baselineTokenStats && withSkillTokenStats && baselineTokenStats.avgTotal > 0) {
|
|
207
|
+
const delta = withSkillTokenStats.avgTotal - baselineTokenStats.avgTotal;
|
|
208
|
+
const deltaSign = delta >= 0 ? '+' : '';
|
|
209
|
+
const deltaPct = Math.round((delta / baselineTokenStats.avgTotal) * 100);
|
|
210
|
+
const deltaStr = `${deltaSign}${formatTokens(Math.abs(delta))} (${deltaSign}${deltaPct}%)`;
|
|
211
|
+
Logger.write(`\n Token Delta: ${delta >= 0 ? chalk.yellow(deltaStr) : chalk.green(deltaStr)}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const baselineDurationStats = metrics.durationStats?.withoutSkill;
|
|
215
|
+
const withSkillDurationStats = metrics.durationStats?.withSkill;
|
|
216
|
+
if (baselineDurationStats || withSkillDurationStats) {
|
|
217
|
+
if (baselineDurationStats)
|
|
218
|
+
Logger.write(`\n Time (w/o skill): ${formatDuration(baselineDurationStats.avgMs)} avg`);
|
|
219
|
+
if (withSkillDurationStats)
|
|
220
|
+
Logger.write(`\n Time (w/ skill): ${formatDuration(withSkillDurationStats.avgMs)} avg`);
|
|
221
|
+
if (baselineDurationStats && withSkillDurationStats && baselineDurationStats.avgMs > 0) {
|
|
222
|
+
const delta = withSkillDurationStats.avgMs - baselineDurationStats.avgMs;
|
|
223
|
+
const deltaSign = delta >= 0 ? '+' : '';
|
|
224
|
+
const deltaPct = Math.round((delta / baselineDurationStats.avgMs) * 100);
|
|
225
|
+
const deltaStr = `${deltaSign}${formatDuration(Math.abs(delta))} (${deltaSign}${deltaPct}%)`;
|
|
226
|
+
Logger.write(`\n Time Delta: ${delta >= 0 ? chalk.yellow(deltaStr) : chalk.green(deltaStr)}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
package/dist/utils/ui.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { Listr } from 'listr2';
|
|
2
|
+
function sanitizeLog(log, maxLen = 50) {
|
|
3
|
+
const sanitized = log.replace(/\n/g, ' ').trim();
|
|
4
|
+
return sanitized.length > maxLen ? sanitized.substring(0, maxLen - 3) + '...' : sanitized;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Listr implementation for rendering parallel task progress
|
|
8
|
+
*/
|
|
9
|
+
export class ListrEvalUI {
|
|
10
|
+
tasks = [];
|
|
11
|
+
addTask(descriptor) {
|
|
12
|
+
const numTrials = descriptor.subtaskLabels
|
|
13
|
+
? descriptor.subtaskLabels.length
|
|
14
|
+
: (descriptor.numTrials ?? 0);
|
|
15
|
+
if (numTrials <= 1) {
|
|
16
|
+
// Single-trial path: existing behaviour unchanged
|
|
17
|
+
this.tasks.push({
|
|
18
|
+
title: descriptor.title,
|
|
19
|
+
task: async (ctx, task) => {
|
|
20
|
+
let taskDone = false;
|
|
21
|
+
const evalCtx = {
|
|
22
|
+
updateLog: (log) => {
|
|
23
|
+
if (taskDone)
|
|
24
|
+
return;
|
|
25
|
+
task.output = sanitizeLog(log);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
try {
|
|
29
|
+
await descriptor.task(evalCtx);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (error instanceof Error)
|
|
33
|
+
error.message = '';
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
taskDone = true;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
// Multi-trial path: one listr2 subtask per trial, each backed by a deferred promise
|
|
44
|
+
this.tasks.push({
|
|
45
|
+
title: descriptor.title,
|
|
46
|
+
task: (ctx, parentTask) => {
|
|
47
|
+
const deferreds = [];
|
|
48
|
+
const outputSetters = Array.from({ length: numTrials }, () => () => { });
|
|
49
|
+
const titleSetters = Array.from({ length: numTrials }, () => () => { });
|
|
50
|
+
const titleSetterReady = new Array(numTrials).fill(false);
|
|
51
|
+
const pendingTitle = new Array(numTrials).fill(undefined);
|
|
52
|
+
const trialDone = new Array(numTrials).fill(false);
|
|
53
|
+
const subtaskBaseLabels = descriptor.subtaskLabels
|
|
54
|
+
? descriptor.subtaskLabels
|
|
55
|
+
: Array.from({ length: numTrials }, (_, idx) => `Trial ${idx + 1}`);
|
|
56
|
+
const subtasks = Array.from({ length: numTrials }, (_, idx) => {
|
|
57
|
+
let resolve, reject;
|
|
58
|
+
const promise = new Promise((res, rej) => {
|
|
59
|
+
resolve = res;
|
|
60
|
+
reject = rej;
|
|
61
|
+
});
|
|
62
|
+
deferreds.push({ resolve, reject });
|
|
63
|
+
return {
|
|
64
|
+
title: subtaskBaseLabels[idx],
|
|
65
|
+
task: async (_, subtask) => {
|
|
66
|
+
outputSetters[idx] = (s) => {
|
|
67
|
+
subtask.output = s;
|
|
68
|
+
};
|
|
69
|
+
titleSetters[idx] = (s) => {
|
|
70
|
+
subtask.title = s;
|
|
71
|
+
};
|
|
72
|
+
titleSetterReady[idx] = true;
|
|
73
|
+
if (pendingTitle[idx] !== undefined) {
|
|
74
|
+
subtask.title = pendingTitle[idx];
|
|
75
|
+
pendingTitle[idx] = undefined;
|
|
76
|
+
}
|
|
77
|
+
await promise;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
const multiCtx = {
|
|
82
|
+
getTrialCtx: (trialId) => ({
|
|
83
|
+
updateLog: (log) => {
|
|
84
|
+
const i = trialId - 1;
|
|
85
|
+
if (trialDone[i])
|
|
86
|
+
return;
|
|
87
|
+
const title = `${subtaskBaseLabels[i]} — ${sanitizeLog(log)}`;
|
|
88
|
+
if (titleSetterReady[i]) {
|
|
89
|
+
titleSetters[i](title);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
pendingTitle[i] = title;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}),
|
|
96
|
+
markTrialComplete: (trialId, passed, failureReason, isError, assertionsPassed, assertionsTotal) => {
|
|
97
|
+
const i = trialId - 1;
|
|
98
|
+
if (trialDone[i])
|
|
99
|
+
return;
|
|
100
|
+
trialDone[i] = true;
|
|
101
|
+
if (passed) {
|
|
102
|
+
titleSetters[i](`${subtaskBaseLabels[i]} — passed`);
|
|
103
|
+
deferreds[i].resolve();
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
if (isError) {
|
|
107
|
+
titleSetters[i](`${subtaskBaseLabels[i]} — error`);
|
|
108
|
+
outputSetters[i]('(!) ERROR');
|
|
109
|
+
}
|
|
110
|
+
else if (assertionsPassed !== undefined && assertionsTotal !== undefined && assertionsPassed > 0) {
|
|
111
|
+
titleSetters[i](`${subtaskBaseLabels[i]} — partial ${assertionsPassed}/${assertionsTotal}`);
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
titleSetters[i](`${subtaskBaseLabels[i]} — not-passed`);
|
|
115
|
+
}
|
|
116
|
+
deferreds[i].reject(new Error(''));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
// Fire-and-forget: task runs concurrently with the subtask lifecycle.
|
|
121
|
+
// Aggregation inside descriptor.task() completes before ui.run() resolves
|
|
122
|
+
// because markTrialComplete() is called before each .then() returns,
|
|
123
|
+
// so all deferreds are resolved before Promise.all() inside descriptor.task() resolves.
|
|
124
|
+
descriptor.task({ updateLog: () => { } }, multiCtx).catch((unexpectedError) => {
|
|
125
|
+
// Reject any pending deferreds so subtasks don't hang
|
|
126
|
+
const err = unexpectedError instanceof Error
|
|
127
|
+
? unexpectedError
|
|
128
|
+
: new Error(String(unexpectedError));
|
|
129
|
+
deferreds.forEach((d, i) => {
|
|
130
|
+
if (!trialDone[i]) {
|
|
131
|
+
trialDone[i] = true;
|
|
132
|
+
try {
|
|
133
|
+
d.reject(err);
|
|
134
|
+
}
|
|
135
|
+
catch (_) { }
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
return parentTask.newListr(subtasks, { concurrent: true, exitOnError: false });
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
async run(concurrency) {
|
|
144
|
+
if (this.tasks.length === 0)
|
|
145
|
+
return;
|
|
146
|
+
// Use simple renderer in non-TTY or tests to avoid hangs and provide clearer logs
|
|
147
|
+
const isTTY = process.stdout.isTTY;
|
|
148
|
+
const isTest = process.env.NODE_ENV === 'test' || process.env.CI === 'true';
|
|
149
|
+
const listr = new Listr(this.tasks, {
|
|
150
|
+
concurrent: concurrency,
|
|
151
|
+
exitOnError: false, // Continue other tasks if one fails
|
|
152
|
+
renderer: (isTTY && !isTest) ? 'default' : 'verbose',
|
|
153
|
+
rendererOptions: {
|
|
154
|
+
collapseSubtasks: false,
|
|
155
|
+
formatOutput: 'wrap'
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
try {
|
|
159
|
+
await listr.run();
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
// Listr throws if any task fails even with exitOnError: false.
|
|
163
|
+
// We catch it here because we handle the results/errors ourselves in the caller.
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|