@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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -0
  3. package/dist/commands/functional.js +225 -0
  4. package/dist/commands/rate.js +20 -0
  5. package/dist/commands/show.js +43 -0
  6. package/dist/commands/trigger.js +154 -0
  7. package/dist/commands/view.js +30 -0
  8. package/dist/core/agent-pool.js +40 -0
  9. package/dist/core/config.js +58 -0
  10. package/dist/core/environment.js +83 -0
  11. package/dist/core/errors.js +29 -0
  12. package/dist/core/eval-runner.js +306 -0
  13. package/dist/core/evaluator.js +242 -0
  14. package/dist/core/preflight.js +36 -0
  15. package/dist/core/reporters/html-reporter.js +354 -0
  16. package/dist/core/reporters/index.js +9 -0
  17. package/dist/core/reporters/json-reporter.js +7 -0
  18. package/dist/core/reporters/reporter.js +1 -0
  19. package/dist/core/runner.js +75 -0
  20. package/dist/core/runners/factory.js +18 -0
  21. package/dist/core/runners/gemini-cli.runner.js +138 -0
  22. package/dist/core/runners/index.js +3 -0
  23. package/dist/core/runners/runner.interface.js +1 -0
  24. package/dist/core/statistics.js +79 -0
  25. package/dist/core/trial-utils.js +58 -0
  26. package/dist/index.js +80 -0
  27. package/dist/reporters/html-reporter.js +384 -0
  28. package/dist/reporters/index.js +2 -0
  29. package/dist/reporters/json-reporter.js +10 -0
  30. package/dist/reporters/reporter.js +1 -0
  31. package/dist/runners/gemini-cli/index.js +1 -0
  32. package/dist/runners/gemini-cli/runner.js +231 -0
  33. package/dist/runners/index.js +2 -0
  34. package/dist/runners/registry.js +16 -0
  35. package/dist/runners/runner.interface.js +1 -0
  36. package/dist/types/index.js +1 -0
  37. package/dist/utils/eval-loader.js +66 -0
  38. package/dist/utils/exec.js +9 -0
  39. package/dist/utils/logger.js +80 -0
  40. package/dist/utils/ndjson.js +85 -0
  41. package/dist/utils/table-renderer.js +229 -0
  42. package/dist/utils/ui.js +166 -0
  43. package/package.json +49 -0
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HeadlessRunner = void 0;
4
+ const child_process_1 = require("child_process");
5
+ class HeadlessRunner {
6
+ agent;
7
+ constructor(agent) {
8
+ this.agent = agent;
9
+ }
10
+ /**
11
+ * Runs the prompt through a headless isolated gemini instance in auto_edit mode.
12
+ * Auto Edit mode automatically allows tools meant for modifying files, falling back interactively
13
+ * only for severe system-level actions (which we assume tests shouldn't invoke, or will fail if they do in non-interactive).
14
+ * @param prompt The evaluation prompt text
15
+ * @returns Parsed JSON output from Gemini
16
+ */
17
+ runPrompt(prompt) {
18
+ if (this.agent !== 'gemini-cli') {
19
+ console.error(`\n[Runner] Agent '${this.agent}' is not supported yet.`);
20
+ return null;
21
+ }
22
+ // Escaping the prompt just in case. spawnSync handles formatting but better safe.
23
+ try {
24
+ const child = (0, child_process_1.spawnSync)('gemini', [
25
+ '-p', prompt,
26
+ '-o', 'json',
27
+ '--approval-mode', 'auto_edit'
28
+ ], {
29
+ encoding: 'utf-8',
30
+ // Not passing default stdio because we need to parse stdout
31
+ // silently without printing all the tool interactions to the user terminal.
32
+ stdio: ['ignore', 'pipe', 'pipe']
33
+ });
34
+ if (child.error) {
35
+ console.error(`\n[Runner] Failed to start gemini CLI. Error: ${child.error.message}`);
36
+ return null;
37
+ }
38
+ // If exit code is not 0, there was likely a crash or unhanded exception in gemini cli.
39
+ if (child.status !== 0) {
40
+ console.error(`\n[Runner] Gemini CLI exited with status ${child.status}`);
41
+ if (child.stderr) {
42
+ console.error(`[Runner Stderr] ${child.stderr.trim()}`);
43
+ }
44
+ }
45
+ const rawOutput = child.stdout;
46
+ if (!rawOutput || rawOutput.trim() === '') {
47
+ console.error(`\n[Runner] Received empty output from Gemini CLI. Cannot evaluate.`);
48
+ return null;
49
+ }
50
+ // We need to safely extract the JSON part because Gemini CLI might emit
51
+ // non-JSON warnings like "MCP issues detected. Run /mcp list for status." to stdout.
52
+ // We'll search for the first '{' to begin our JSON payload.
53
+ let jsonPart = rawOutput.trim();
54
+ const firstBraceIndex = jsonPart.indexOf('{');
55
+ if (firstBraceIndex > 0) {
56
+ jsonPart = jsonPart.substring(firstBraceIndex);
57
+ }
58
+ try {
59
+ const parsed = JSON.parse(jsonPart);
60
+ return parsed;
61
+ }
62
+ catch (parseError) {
63
+ console.error(`\n[Runner] Failed to parse JSON output: ${parseError}`);
64
+ // Dump first 300 chars of raw to debug
65
+ console.error(`[Runner Output Preview] ${rawOutput.substring(0, 300)}`);
66
+ return null;
67
+ }
68
+ }
69
+ catch (e) {
70
+ console.error(`\n[Runner] Unexpected error running process: ${e}`);
71
+ return null;
72
+ }
73
+ }
74
+ }
75
+ exports.HeadlessRunner = HeadlessRunner;
@@ -0,0 +1,18 @@
1
+ import { GeminiCliRunner } from './gemini-cli.runner.js';
2
+ export class RunnerFactory {
3
+ /**
4
+ * Factory method to create an agent runner based on the agent name.
5
+ * Add new agent implementations here.
6
+ * @param agent The name of the agent to run
7
+ * @returns An implementation of AgentRunner
8
+ * @throws Error if the agent is not supported
9
+ */
10
+ static create(agent) {
11
+ switch (agent) {
12
+ case 'gemini-cli':
13
+ return new GeminiCliRunner();
14
+ default:
15
+ throw new Error(`Agent '${agent}' is not supported yet.`);
16
+ }
17
+ }
18
+ }
@@ -0,0 +1,138 @@
1
+ import * as fs from 'fs';
2
+ import child_process from 'child_process';
3
+ import { Logger } from '../../utils/logger.js';
4
+ export class GeminiCliRunner {
5
+ /**
6
+ * Runs the prompt through an isolated gemini instance.
7
+ * Default mode is headless using --approval-mode auto_edit.
8
+ *
9
+ * @param prompt The evaluation prompt text
10
+ * @param cwd Optional execution directory
11
+ * @param onLog Callback to receive real-time logs (from stderr)
12
+ * @param logPath Optional file path to save raw execution logs
13
+ * @returns Raw output from Gemini
14
+ */
15
+ async runPrompt(prompt, cwd, onLog, logPath, extraArgs = []) {
16
+ return new Promise((resolve) => {
17
+ let stdout = '';
18
+ let stderr = '';
19
+ let resolved = false;
20
+ // Use -p and --approval-mode auto_edit for headless mode.
21
+ const args = ['-p', prompt, '--approval-mode', 'auto_edit', ...extraArgs];
22
+ const spawnOptions = {
23
+ cwd: cwd,
24
+ env: { ...process.env, FORCE_COLOR: '1' }
25
+ };
26
+ const child = child_process.spawn('gemini', args, spawnOptions);
27
+ // Setup log stream if path is provided
28
+ let logStream = null;
29
+ let logStreamDone = true; // Default to true if no logPath
30
+ if (logPath) {
31
+ try {
32
+ logStream = fs.createWriteStream(logPath, { flags: 'a' });
33
+ logStreamDone = false;
34
+ logStream.on('finish', () => {
35
+ logStreamDone = true;
36
+ checkAllDone();
37
+ });
38
+ logStream.write(`--- Gemini CLI Execution Start: ${new Date().toISOString()} ---\n`);
39
+ logStream.write(`Command: gemini ${args.join(' ')}\n\n`);
40
+ }
41
+ catch (err) {
42
+ Logger.warn(`Failed to create log file at ${logPath} — verbose output will not be saved. Continuing. Reason: ${err}`);
43
+ logStreamDone = true;
44
+ }
45
+ }
46
+ // Safety timeout: 5 minutes (300,000 ms)
47
+ const timeout = setTimeout(() => {
48
+ if (!resolved) {
49
+ resolved = true;
50
+ child.kill('SIGKILL');
51
+ if (logStream) {
52
+ logStream.write('\n\n--- Gemini CLI process timed out ---\n');
53
+ logStream.end();
54
+ }
55
+ Logger.error('\nGemini CLI process timed out after 5 minutes.');
56
+ resolve({ error: 'Process timeout exceeded (5 minutes)', raw_output: stderr });
57
+ }
58
+ }, 300000);
59
+ // Track completion of streams
60
+ let stdoutDone = false;
61
+ let stderrDone = false;
62
+ let processDone = false;
63
+ function checkAllDone() {
64
+ if (stdoutDone && stderrDone && processDone && logStreamDone && !resolved) {
65
+ resolved = true;
66
+ clearTimeout(timeout);
67
+ if (!stdout || stdout.trim() === '') {
68
+ return resolve({ error: 'Empty output from Gemini CLI', raw_output: stderr });
69
+ }
70
+ return resolve({
71
+ response: stdout.trim(),
72
+ raw_output: `${stdout}\n--- STDERR ---\n${stderr}`
73
+ });
74
+ }
75
+ }
76
+ if (child.stdout) {
77
+ child.stdout.on('data', (data) => {
78
+ const chunk = data.toString();
79
+ stdout += chunk;
80
+ if (logStream)
81
+ logStream.write(chunk);
82
+ });
83
+ child.stdout.on('end', () => {
84
+ stdoutDone = true;
85
+ checkAllDone();
86
+ });
87
+ }
88
+ else {
89
+ stdoutDone = true;
90
+ }
91
+ if (child.stderr) {
92
+ child.stderr.on('data', (data) => {
93
+ const chunk = data.toString();
94
+ stderr += chunk;
95
+ if (onLog) {
96
+ const lines = chunk.split('\n').filter((l) => l.trim() !== '');
97
+ if (lines.length > 0) {
98
+ onLog(lines[lines.length - 1]);
99
+ }
100
+ }
101
+ });
102
+ child.stderr.on('end', () => {
103
+ stderrDone = true;
104
+ checkAllDone();
105
+ });
106
+ }
107
+ else {
108
+ stderrDone = true;
109
+ }
110
+ child.on('error', (err) => {
111
+ if (!resolved) {
112
+ resolved = true;
113
+ clearTimeout(timeout);
114
+ if (logStream) {
115
+ logStream.write(`\n\n--- Error starting Gemini CLI: ${err.message} ---\n`);
116
+ logStream.end();
117
+ }
118
+ Logger.error(`Failed to start gemini CLI. Error: ${err.message}`);
119
+ resolve(null);
120
+ }
121
+ });
122
+ child.on('close', (code) => {
123
+ if (logStream) {
124
+ logStream.write(`\n\n--- Gemini CLI exited with status ${code} ---\n`);
125
+ logStream.end();
126
+ }
127
+ if (code !== 0 && !resolved) {
128
+ Logger.error(`Gemini CLI exited with status ${code}`);
129
+ if (stderr) {
130
+ Logger.debug(`Gemini CLI Stderr: ${stderr.trim()}`);
131
+ }
132
+ }
133
+ processDone = true;
134
+ checkAllDone();
135
+ });
136
+ });
137
+ }
138
+ }
@@ -0,0 +1,3 @@
1
+ export * from './runner.interface.js';
2
+ export * from './gemini-cli.runner.js';
3
+ export * from './factory.js';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Computes the fraction of individual assertions that passed across all non-error trials.
3
+ * Returns 0 if there are no non-error trials or no assertions.
4
+ */
5
+ export function computeAssertionPassRate(trials) {
6
+ const relevant = trials.filter(t => !t.isError);
7
+ if (relevant.length === 0)
8
+ return 0;
9
+ const total = relevant.reduce((s, t) => s + t.assertionResults.length, 0);
10
+ if (total === 0)
11
+ return 0;
12
+ const passed = relevant.reduce((s, t) => s + t.assertionResults.filter(r => r.passed).length, 0);
13
+ return passed / total;
14
+ }
15
+ /**
16
+ * Averages computeAssertionPassRate across all task results.
17
+ */
18
+ export function aggregateAssertionPassRate(results, trialSelector) {
19
+ if (results.length === 0)
20
+ return 0;
21
+ return results.reduce((sum, r) => sum + computeAssertionPassRate(trialSelector(r)), 0) / results.length;
22
+ }
23
+ /**
24
+ * Computes the pass rate (pass@1) for a set of trials.
25
+ * Returns the fraction of trials that passed: c / n.
26
+ */
27
+ export function computePassAtK(trials, _k = 1) {
28
+ const n = trials.length;
29
+ if (n === 0)
30
+ return 0;
31
+ return trials.filter(t => t.trialPassed).length / n;
32
+ }
33
+ /**
34
+ * Aggregates pass@1 across all task results.
35
+ * Returns the average pass rate over all tasks.
36
+ *
37
+ * @param results Array of task results to aggregate.
38
+ * @param _numTrials Unused — kept for call-site compatibility.
39
+ * @param trialSelector Function that extracts the trial array from a TaskResult.
40
+ */
41
+ export function aggregatePassAtK(results, _numTrials, trialSelector) {
42
+ if (results.length === 0)
43
+ return { passAtK: 0 };
44
+ return {
45
+ passAtK: results.reduce((sum, r) => sum + computePassAtK(trialSelector(r)), 0) / results.length
46
+ };
47
+ }
48
+ /**
49
+ * Computes average duration across trials that have durationMs set.
50
+ * Returns null if no trial has duration data.
51
+ */
52
+ export function aggregateDurationStats(trials) {
53
+ const withDuration = trials.filter(t => t.durationMs != null);
54
+ if (withDuration.length === 0)
55
+ return null;
56
+ const n = withDuration.length;
57
+ return {
58
+ avgMs: Math.round(withDuration.reduce((s, t) => s + t.durationMs, 0) / n),
59
+ trialCount: n,
60
+ };
61
+ }
62
+ /**
63
+ * Computes average token consumption across trials that have token stats.
64
+ * Trials without tokenStats are excluded from the average.
65
+ * Returns null if no trial has token stats.
66
+ */
67
+ export function aggregateTokenStats(trials) {
68
+ const withStats = trials.filter(t => t.tokenStats);
69
+ if (withStats.length === 0)
70
+ return null;
71
+ const n = withStats.length;
72
+ return {
73
+ avgTotal: Math.round(withStats.reduce((s, t) => s + t.tokenStats.totalTokens, 0) / n),
74
+ avgInput: Math.round(withStats.reduce((s, t) => s + t.tokenStats.inputTokens, 0) / n),
75
+ avgOutput: Math.round(withStats.reduce((s, t) => s + t.tokenStats.outputTokens, 0) / n),
76
+ avgCached: Math.round(withStats.reduce((s, t) => s + t.tokenStats.cachedTokens, 0) / n),
77
+ trialCount: n,
78
+ };
79
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Returns true when a trial represents an infrastructure failure (timeout, blocked
3
+ * interactive prompt, runner crash, etc.) rather than a legitimate judge verdict.
4
+ * Infrastructure-error trials are candidates for retry via withRetry().
5
+ */
6
+ export function isTrialError(trial) {
7
+ return trial.isError === true;
8
+ }
9
+ /**
10
+ * Runs fn(), retrying up to maxRetries additional times with exponential backoff
11
+ * whenever the result is an infrastructure-error trial (isTrialError returns true).
12
+ * A successful judge verdict (pass OR fail) stops retrying immediately.
13
+ *
14
+ * Delays: attempt 1 → baseDelayMs, attempt 2 → baseDelayMs * 2
15
+ *
16
+ * @param onRetry Optional callback fired after an error result, before the next attempt.
17
+ * Receives the upcoming attempt number (1-based) and the failed trial.
18
+ * Use it to surface retry progress in the UI.
19
+ */
20
+ export async function withRetry(fn, maxRetries = 2, baseDelayMs = 1000, onRetry) {
21
+ let last;
22
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
23
+ if (attempt > 0) {
24
+ await new Promise(resolve => setTimeout(resolve, baseDelayMs * Math.pow(2, attempt - 1)));
25
+ }
26
+ last = await fn(attempt);
27
+ if (!isTrialError(last))
28
+ return last;
29
+ if (attempt < maxRetries)
30
+ onRetry?.(attempt + 1, last);
31
+ }
32
+ return last;
33
+ }
34
+ /**
35
+ * Pads the trials array up to targetCount when a trial loop aborts early.
36
+ * Ensures that pass@k calculations always reflect the full requested trial count.
37
+ *
38
+ * @param trials Trials collected so far (may be shorter than targetCount).
39
+ * @param targetCount The requested number of trials (numTrials).
40
+ * @param assertionLabel The assertion label to use for the padded entries (e.g. 'Runner Execution').
41
+ */
42
+ export function padAbortedTrials(trials, targetCount, assertionLabel) {
43
+ while (trials.length < targetCount) {
44
+ trials.push({
45
+ id: trials.length + 1,
46
+ transcript: { error: 'Trial not executed (previous trial aborted)' },
47
+ assertionResults: [{
48
+ assertion: assertionLabel,
49
+ passed: false,
50
+ reason: 'Trial not executed (previous trial aborted)',
51
+ graderType: 'programmatic'
52
+ }],
53
+ trialPassed: false,
54
+ isError: true
55
+ });
56
+ }
57
+ return trials;
58
+ }
package/dist/index.js ADDED
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { triggerCommand } from './commands/trigger.js';
4
+ import { functionalCommand } from './commands/functional.js';
5
+ import { Logger } from './utils/logger.js';
6
+ import { AppError } from './core/errors.js';
7
+ import { HtmlReporter } from './reporters/index.js';
8
+ import { DEFAULT_AGENT } from './runners/registry.js';
9
+ import * as path from 'path';
10
+ import * as fs from 'fs';
11
+ import { fileURLToPath } from 'url';
12
+ export const program = new Command();
13
+ const errorHandler = (err) => {
14
+ if (err instanceof AppError) {
15
+ Logger.error(err.message);
16
+ }
17
+ else if (err instanceof Error) {
18
+ Logger.error(`An unexpected error occurred: ${err.message}`);
19
+ Logger.trace(err);
20
+ }
21
+ else {
22
+ Logger.error(`An unknown error occurred: ${String(err)}`);
23
+ }
24
+ process.exit(1);
25
+ };
26
+ program
27
+ .name('skill-eval')
28
+ .description('CLI to evaluate agent skills triggering and functionality')
29
+ .version('1.0.0')
30
+ .option('-v, --debug', 'Enable debug logging', false);
31
+ program.on('option:debug', () => {
32
+ process.env.DEBUG = 'true';
33
+ });
34
+ program
35
+ .command('trigger [agent]')
36
+ .description('Evaluate triggering of an agent skill')
37
+ .requiredOption('--workspace <path>', 'Path to the workspace/repo to evaluate against')
38
+ .requiredOption('--skill <path>', 'Path to the skill directory')
39
+ .option('--agents <number>', 'Number of parallel agents')
40
+ .option('--trials <number>', 'Number of trials per task for pass@k calculation')
41
+ .option('--timeout <seconds>', 'Agent timeout in seconds')
42
+ .option('--eval-id <id>', 'Run only the eval with this ID (numeric)')
43
+ .action((agent, options) => {
44
+ const workspace = path.resolve(options.workspace);
45
+ const selectedAgent = agent || DEFAULT_AGENT;
46
+ const maxAgents = parseInt(options.agents, 10) || 4;
47
+ const numTrials = options.trials !== undefined ? (parseInt(options.trials, 10) || 3) : 3;
48
+ const timeoutMs = options.timeout ? parseInt(options.timeout, 10) * 1000 : undefined;
49
+ const evalId = options.evalId !== undefined ? parseInt(options.evalId, 10) : undefined;
50
+ triggerCommand(selectedAgent, workspace, options.skill, maxAgents, undefined, numTrials, new HtmlReporter(), timeoutMs, evalId).catch(errorHandler);
51
+ });
52
+ program
53
+ .command('functional [agent]')
54
+ .description('Evaluate functional correctness of an agent skill based on assertions')
55
+ .requiredOption('--workspace <path>', 'Path to the workspace/repo to evaluate against')
56
+ .requiredOption('--skill <path>', 'Path to the skill directory')
57
+ .option('--agents <number>', 'Number of parallel agents')
58
+ .option('--trials <number>', 'Number of trials per task for pass@k calculation')
59
+ .option('--timeout <seconds>', 'Agent timeout in seconds')
60
+ .option('--eval-id <id>', 'Run only the eval with this ID (numeric)')
61
+ .action((agent, options) => {
62
+ const workspace = path.resolve(options.workspace);
63
+ const selectedAgent = agent || DEFAULT_AGENT;
64
+ const maxAgents = parseInt(options.agents, 10) || 4;
65
+ const numTrials = options.trials !== undefined ? (parseInt(options.trials, 10) || 3) : 3;
66
+ const timeoutMs = options.timeout ? parseInt(options.timeout, 10) * 1000 : undefined;
67
+ const evalId = options.evalId !== undefined ? parseInt(options.evalId, 10) : undefined;
68
+ functionalCommand(selectedAgent, workspace, options.skill, maxAgents, undefined, numTrials, new HtmlReporter(), timeoutMs, evalId).catch(errorHandler);
69
+ });
70
+ const isMain = process.argv[1] && (() => {
71
+ try {
72
+ return fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ })();
78
+ if (isMain) {
79
+ program.parse(process.argv);
80
+ }