@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,384 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { Logger } from '../utils/logger.js';
|
|
4
|
+
import { formatTokens, formatDuration } from '../utils/table-renderer.js';
|
|
5
|
+
import { computeAssertionPassRate } from '../core/statistics.js';
|
|
6
|
+
export class HtmlReporter {
|
|
7
|
+
generate(report, runDir) {
|
|
8
|
+
const htmlPath = path.join(runDir, 'report.html');
|
|
9
|
+
fs.writeFileSync(htmlPath, generateHtml(report), 'utf-8');
|
|
10
|
+
Logger.write(`\n Report: file://${htmlPath}\n`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// HTML generation (module-private)
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
function escapeHtml(s) {
|
|
17
|
+
return s
|
|
18
|
+
.replace(/&/g, '&')
|
|
19
|
+
.replace(/</g, '<')
|
|
20
|
+
.replace(/>/g, '>')
|
|
21
|
+
.replace(/"/g, '"')
|
|
22
|
+
.replace(/'/g, ''');
|
|
23
|
+
}
|
|
24
|
+
function formatPercent(val) {
|
|
25
|
+
return `${Math.round(val * 100)}%`;
|
|
26
|
+
}
|
|
27
|
+
function passColorClass(val) {
|
|
28
|
+
if (val >= 0.8)
|
|
29
|
+
return 'green';
|
|
30
|
+
if (val >= 0.5)
|
|
31
|
+
return 'amber';
|
|
32
|
+
return 'red';
|
|
33
|
+
}
|
|
34
|
+
function isFunctional(report) {
|
|
35
|
+
return report.metrics.withoutSkillScore !== undefined;
|
|
36
|
+
}
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Metrics Grid
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
function renderDeltaCell(base, target, format, cellClass) {
|
|
41
|
+
if (base <= 0)
|
|
42
|
+
return `<span class="metric-val muted">—</span>`;
|
|
43
|
+
const delta = target - base;
|
|
44
|
+
const sign = delta >= 0 ? '+' : '';
|
|
45
|
+
const pct = Math.round((delta / base) * 100);
|
|
46
|
+
const cls = delta > 0 ? 'amber' : delta < 0 ? 'green' : '';
|
|
47
|
+
return `<span class="${cellClass} ${cls}">${sign}${pct}%</span>`;
|
|
48
|
+
}
|
|
49
|
+
function renderMetricsGrid(report) {
|
|
50
|
+
const { metrics } = report;
|
|
51
|
+
const functional = isFunctional(report);
|
|
52
|
+
if (functional) {
|
|
53
|
+
const bk = metrics.withoutSkillAssertionPassRate ?? metrics.withoutSkillPassAtK ?? 0;
|
|
54
|
+
const tk = metrics.assertionPassRate ?? metrics.passAtK ?? 0;
|
|
55
|
+
const upliftRaw = parseInt(metrics.skillUplift ?? '0', 10);
|
|
56
|
+
const upliftClass = upliftRaw > 0 ? 'green' : upliftRaw < 0 ? 'red' : '';
|
|
57
|
+
const wo = metrics.tokenStats?.withoutSkill;
|
|
58
|
+
const wi = metrics.tokenStats?.withSkill;
|
|
59
|
+
const wod = metrics.durationStats?.withoutSkill;
|
|
60
|
+
const wid = metrics.durationStats?.withSkill;
|
|
61
|
+
return `<div class="metrics-grid">
|
|
62
|
+
<table>
|
|
63
|
+
<thead>
|
|
64
|
+
<tr><th></th><th>Without Skill</th><th>With Skill</th><th>Delta</th></tr>
|
|
65
|
+
</thead>
|
|
66
|
+
<tbody>
|
|
67
|
+
<tr>
|
|
68
|
+
<td>Success Rate</td>
|
|
69
|
+
<td><span class="metric-val ${passColorClass(bk)}">${formatPercent(bk)}</span></td>
|
|
70
|
+
<td><span class="metric-val ${passColorClass(tk)}">${formatPercent(tk)}</span></td>
|
|
71
|
+
<td><span class="metric-val ${upliftClass}">${escapeHtml(metrics.skillUplift ?? '0%')}</span></td>
|
|
72
|
+
</tr>
|
|
73
|
+
<tr>
|
|
74
|
+
<td>Tokens (avg)</td>
|
|
75
|
+
<td>${wo ? `<span class="metric-val">${formatTokens(wo.avgTotal)}</span><div class="metric-sub">avg total</div>` : '<span class="metric-val muted">—</span>'}</td>
|
|
76
|
+
<td>${wi ? `<span class="metric-val">${formatTokens(wi.avgTotal)}</span><div class="metric-sub">avg total</div>` : '<span class="metric-val muted">—</span>'}</td>
|
|
77
|
+
<td>${wo && wi ? renderDeltaCell(wo.avgTotal, wi.avgTotal, formatTokens, 'metric-val') : '<span class="metric-val muted">—</span>'}</td>
|
|
78
|
+
</tr>
|
|
79
|
+
<tr>
|
|
80
|
+
<td>Time (avg)</td>
|
|
81
|
+
<td>${wod ? `<span class="metric-val">${formatDuration(wod.avgMs)}</span>` : '<span class="metric-val muted">—</span>'}</td>
|
|
82
|
+
<td>${wid ? `<span class="metric-val">${formatDuration(wid.avgMs)}</span>` : '<span class="metric-val muted">—</span>'}</td>
|
|
83
|
+
<td>${wod && wid ? renderDeltaCell(wod.avgMs, wid.avgMs, formatDuration, 'metric-val') : '<span class="metric-val muted">—</span>'}</td>
|
|
84
|
+
</tr>
|
|
85
|
+
</tbody>
|
|
86
|
+
</table>
|
|
87
|
+
</div>`;
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
const k = metrics.passAtK ?? 0;
|
|
91
|
+
const wi = metrics.tokenStats?.withSkill;
|
|
92
|
+
const wid = metrics.durationStats?.withSkill;
|
|
93
|
+
return `<div class="metrics-grid">
|
|
94
|
+
<table>
|
|
95
|
+
<thead>
|
|
96
|
+
<tr><th></th><th>Score</th></tr>
|
|
97
|
+
</thead>
|
|
98
|
+
<tbody>
|
|
99
|
+
<tr>
|
|
100
|
+
<td>Success Rate</td>
|
|
101
|
+
<td><span class="metric-val ${passColorClass(k)}">${formatPercent(k)}</span></td>
|
|
102
|
+
</tr>
|
|
103
|
+
<tr>
|
|
104
|
+
<td>Tokens (avg)</td>
|
|
105
|
+
<td>${wi ? `<span class="metric-val">${formatTokens(wi.avgTotal)}</span><div class="metric-sub">avg total</div>` : '<span class="metric-val muted">—</span>'}</td>
|
|
106
|
+
</tr>
|
|
107
|
+
<tr>
|
|
108
|
+
<td>Time (avg)</td>
|
|
109
|
+
<td>${wid ? `<span class="metric-val">${formatDuration(wid.avgMs)}</span>` : '<span class="metric-val muted">—</span>'}</td>
|
|
110
|
+
</tr>
|
|
111
|
+
</tbody>
|
|
112
|
+
</table>
|
|
113
|
+
</div>`;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Trial details
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
function renderAssertions(assertions) {
|
|
120
|
+
if (assertions.length === 0)
|
|
121
|
+
return '<p class="muted">No assertions recorded.</p>';
|
|
122
|
+
return assertions.map(a => {
|
|
123
|
+
const icon = a.passed ? '✓' : '✗';
|
|
124
|
+
const cls = a.passed ? 'assert-pass' : 'assert-fail';
|
|
125
|
+
return `<div class="assertion ${cls}">
|
|
126
|
+
<span class="assert-icon">${icon}</span>
|
|
127
|
+
<div class="assert-body">
|
|
128
|
+
<div class="assert-text">${escapeHtml(a.assertion)}</div>
|
|
129
|
+
${a.reason ? `<div class="assert-reason">${escapeHtml(a.reason)}</div>` : ''}
|
|
130
|
+
</div>
|
|
131
|
+
</div>`;
|
|
132
|
+
}).join('');
|
|
133
|
+
}
|
|
134
|
+
function renderTrial(trial) {
|
|
135
|
+
const passedCount = trial.assertionResults.filter(r => r.passed).length;
|
|
136
|
+
const totalCount = trial.assertionResults.length;
|
|
137
|
+
const isPartial = !trial.trialPassed && !trial.isError && passedCount > 0;
|
|
138
|
+
const cls = trial.isError ? 'trial-error' : trial.trialPassed ? 'trial-pass' : isPartial ? 'trial-partial' : 'trial-fail';
|
|
139
|
+
const badge = trial.isError
|
|
140
|
+
? '<span class="pill amber">! ERROR</span>'
|
|
141
|
+
: trial.trialPassed
|
|
142
|
+
? '<span class="pill green">✓ PASS</span>'
|
|
143
|
+
: isPartial
|
|
144
|
+
? `<span class="pill amber">~ PARTIAL ${passedCount}/${totalCount}</span>`
|
|
145
|
+
: '<span class="pill red">✗ NOT PASSED</span>';
|
|
146
|
+
return `<div class="trial ${cls}">
|
|
147
|
+
<div class="trial-header">Trial ${trial.id} ${badge}</div>
|
|
148
|
+
<div class="trial-assertions">${renderAssertions(trial.assertionResults)}</div>
|
|
149
|
+
</div>`;
|
|
150
|
+
}
|
|
151
|
+
function avgTrialTokens(trials) {
|
|
152
|
+
const withStats = trials.filter(t => t.tokenStats != null);
|
|
153
|
+
if (withStats.length === 0)
|
|
154
|
+
return null;
|
|
155
|
+
return Math.round(withStats.reduce((s, t) => s + t.tokenStats.totalTokens, 0) / withStats.length);
|
|
156
|
+
}
|
|
157
|
+
function avgTrialDuration(trials) {
|
|
158
|
+
const withDuration = trials.filter(t => t.durationMs != null);
|
|
159
|
+
if (withDuration.length === 0)
|
|
160
|
+
return null;
|
|
161
|
+
return Math.round(withDuration.reduce((s, t) => s + t.durationMs, 0) / withDuration.length);
|
|
162
|
+
}
|
|
163
|
+
function renderTaskMiniGrid(result) {
|
|
164
|
+
const woTrials = result.withoutSkillTrials ?? [];
|
|
165
|
+
const wiTrials = result.trials;
|
|
166
|
+
const bk = woTrials.length ? Math.round(computeAssertionPassRate(woTrials) * 100) : 0;
|
|
167
|
+
const tk = wiTrials.length ? Math.round(computeAssertionPassRate(wiTrials) * 100) : 0;
|
|
168
|
+
const rateDelta = tk - bk;
|
|
169
|
+
const rateDeltaSign = rateDelta >= 0 ? '+' : '';
|
|
170
|
+
const rateDeltaClass = rateDelta > 0 ? 'green' : rateDelta < 0 ? 'red' : '';
|
|
171
|
+
const woTokens = avgTrialTokens(woTrials);
|
|
172
|
+
const wiTokens = avgTrialTokens(wiTrials);
|
|
173
|
+
const woMs = avgTrialDuration(woTrials);
|
|
174
|
+
const wiMs = avgTrialDuration(wiTrials);
|
|
175
|
+
const tokenDeltaCell = (woTokens && wiTokens)
|
|
176
|
+
? renderDeltaCell(woTokens, wiTokens, formatTokens, 'metric-val-sm')
|
|
177
|
+
: '<span class="metric-val-sm muted">—</span>';
|
|
178
|
+
const durationDeltaCell = (woMs && wiMs)
|
|
179
|
+
? renderDeltaCell(woMs, wiMs, formatDuration, 'metric-val-sm')
|
|
180
|
+
: '<span class="metric-val-sm muted">—</span>';
|
|
181
|
+
return `<div class="metrics-grid-sm">
|
|
182
|
+
<table>
|
|
183
|
+
<thead>
|
|
184
|
+
<tr><th></th><th>Without Skill</th><th>With Skill</th><th>Delta</th></tr>
|
|
185
|
+
</thead>
|
|
186
|
+
<tbody>
|
|
187
|
+
<tr>
|
|
188
|
+
<td>Success Rate</td>
|
|
189
|
+
<td><span class="metric-val-sm ${passColorClass(bk / 100)}">${bk}%</span></td>
|
|
190
|
+
<td><span class="metric-val-sm ${passColorClass(tk / 100)}">${tk}%</span></td>
|
|
191
|
+
<td><span class="metric-val-sm ${rateDeltaClass}">${rateDeltaSign}${rateDelta}%</span></td>
|
|
192
|
+
</tr>
|
|
193
|
+
<tr>
|
|
194
|
+
<td>Tokens (avg)</td>
|
|
195
|
+
<td>${woTokens != null ? `<span class="metric-val-sm">${formatTokens(woTokens)}</span>` : '<span class="metric-val-sm muted">—</span>'}</td>
|
|
196
|
+
<td>${wiTokens != null ? `<span class="metric-val-sm">${formatTokens(wiTokens)}</span>` : '<span class="metric-val-sm muted">—</span>'}</td>
|
|
197
|
+
<td>${tokenDeltaCell}</td>
|
|
198
|
+
</tr>
|
|
199
|
+
<tr>
|
|
200
|
+
<td>Time (avg)</td>
|
|
201
|
+
<td>${woMs != null ? `<span class="metric-val-sm">${formatDuration(woMs)}</span>` : '<span class="metric-val-sm muted">—</span>'}</td>
|
|
202
|
+
<td>${wiMs != null ? `<span class="metric-val-sm">${formatDuration(wiMs)}</span>` : '<span class="metric-val-sm muted">—</span>'}</td>
|
|
203
|
+
<td>${durationDeltaCell}</td>
|
|
204
|
+
</tr>
|
|
205
|
+
</tbody>
|
|
206
|
+
</table>
|
|
207
|
+
</div>`;
|
|
208
|
+
}
|
|
209
|
+
function renderTaskDetails(result, isFunctionalEval) {
|
|
210
|
+
const sections = [];
|
|
211
|
+
if (isFunctionalEval) {
|
|
212
|
+
sections.push(renderTaskMiniGrid(result));
|
|
213
|
+
}
|
|
214
|
+
if (isFunctionalEval && result.withoutSkillTrials && result.withoutSkillTrials.length > 0) {
|
|
215
|
+
sections.push('<div class="trial-group-label">Without Skill</div>');
|
|
216
|
+
sections.push(...result.withoutSkillTrials.map(t => renderTrial(t)));
|
|
217
|
+
sections.push('<div class="trial-group-label">With Skill</div>');
|
|
218
|
+
}
|
|
219
|
+
sections.push(...result.trials.map(t => renderTrial(t)));
|
|
220
|
+
return `<div class="task-details" id="details-${result.taskId}">${sections.join('')}</div>`;
|
|
221
|
+
}
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// Eval results table
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
function renderTaskTable(report) {
|
|
226
|
+
const { results } = report;
|
|
227
|
+
const functional = isFunctional(report);
|
|
228
|
+
const headerRow = `<tr><th>#</th><th>Prompt</th><th>Details</th></tr>`;
|
|
229
|
+
const rows = results.map(result => {
|
|
230
|
+
const prompt = escapeHtml(result.prompt);
|
|
231
|
+
const detailsBtn = `<button class="details-btn" data-target="details-${result.taskId}">▶</button>`;
|
|
232
|
+
const detailsRow = `<tr class="details-row"><td colspan="3">${renderTaskDetails(result, functional)}</td></tr>`;
|
|
233
|
+
return `<tr><td>${result.taskId}</td><td class="prompt-cell">${prompt}</td><td>${detailsBtn}</td></tr>${detailsRow}`;
|
|
234
|
+
}).join('');
|
|
235
|
+
return `<div class="table-wrap"><table><thead>${headerRow}</thead><tbody>${rows}</tbody></table></div>`;
|
|
236
|
+
}
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// Full document
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
export function generateHtml(report) {
|
|
241
|
+
const { skill_name, agent, timestamp, metrics } = report;
|
|
242
|
+
const functional = isFunctional(report);
|
|
243
|
+
const evalType = functional ? 'Functional' : 'Trigger';
|
|
244
|
+
const overallScore = metrics.passAtK ?? 0;
|
|
245
|
+
const statusClass = passColorClass(overallScore);
|
|
246
|
+
const formattedDate = new Date(timestamp).toLocaleString();
|
|
247
|
+
return `<!DOCTYPE html>
|
|
248
|
+
<html lang="en">
|
|
249
|
+
<head>
|
|
250
|
+
<meta charset="UTF-8">
|
|
251
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
252
|
+
<title>Skill Eval — ${escapeHtml(skill_name)}</title>
|
|
253
|
+
<style>
|
|
254
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
255
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f8fafc; color: #1e293b; font-size: 14px; }
|
|
256
|
+
a { color: #3b82f6; }
|
|
257
|
+
|
|
258
|
+
/* Layout */
|
|
259
|
+
.container { max-width: 960px; margin: 0 auto; padding: 24px 16px 48px; }
|
|
260
|
+
|
|
261
|
+
/* Header */
|
|
262
|
+
.header { background: #1e293b; color: #f1f5f9; padding: 24px 28px; border-radius: 10px; margin-bottom: 24px; }
|
|
263
|
+
.header h1 { font-size: 22px; font-weight: 700; margin-bottom: 8px; }
|
|
264
|
+
.header-meta { display: flex; gap: 24px; flex-wrap: wrap; font-size: 13px; color: #94a3b8; }
|
|
265
|
+
.header-meta span b { color: #e2e8f0; }
|
|
266
|
+
.status-bar { height: 4px; border-radius: 2px; margin-top: 16px; }
|
|
267
|
+
.status-bar.green { background: #22c55e; }
|
|
268
|
+
.status-bar.amber { background: #f59e0b; }
|
|
269
|
+
.status-bar.red { background: #ef4444; }
|
|
270
|
+
|
|
271
|
+
/* Metrics Grid */
|
|
272
|
+
.metrics-grid { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; margin-bottom: 24px; }
|
|
273
|
+
.metrics-grid table { width: 100%; border-collapse: collapse; }
|
|
274
|
+
.metrics-grid thead th { background: #f8fafc; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: #64748b; padding: 10px 24px; text-align: right; border-bottom: 2px solid #e2e8f0; white-space: nowrap; }
|
|
275
|
+
.metrics-grid thead th:first-child { text-align: left; min-width: 120px; }
|
|
276
|
+
.metrics-grid tbody td { padding: 14px 24px; border-bottom: 1px solid #f1f5f9; text-align: right; vertical-align: middle; }
|
|
277
|
+
.metrics-grid tbody tr:last-child td { border-bottom: none; }
|
|
278
|
+
.metrics-grid tbody td:first-child { text-align: left; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: #64748b; white-space: nowrap; }
|
|
279
|
+
.metric-val { font-size: 24px; font-weight: 700; line-height: 1; display: block; }
|
|
280
|
+
.metric-sub { font-size: 11px; color: #94a3b8; margin-top: 3px; }
|
|
281
|
+
|
|
282
|
+
/* Metrics Grid — compact variant (inside task details) */
|
|
283
|
+
.metrics-grid-sm { border: 1px solid #e2e8f0; border-radius: 6px; overflow: hidden; margin-bottom: 14px; }
|
|
284
|
+
.metrics-grid-sm table { width: 100%; border-collapse: collapse; }
|
|
285
|
+
.metrics-grid-sm thead th { background: #f8fafc; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: #94a3b8; padding: 6px 14px; text-align: right; border-bottom: 1px solid #e2e8f0; white-space: nowrap; }
|
|
286
|
+
.metrics-grid-sm thead th:first-child { text-align: left; }
|
|
287
|
+
.metrics-grid-sm tbody td { padding: 8px 14px; border-bottom: 1px solid #f1f5f9; text-align: right; vertical-align: middle; }
|
|
288
|
+
.metrics-grid-sm tbody tr:last-child td { border-bottom: none; }
|
|
289
|
+
.metrics-grid-sm tbody td:first-child { text-align: left; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: #94a3b8; white-space: nowrap; }
|
|
290
|
+
.metric-val-sm { font-size: 15px; font-weight: 700; line-height: 1; display: block; }
|
|
291
|
+
|
|
292
|
+
/* Color utilities */
|
|
293
|
+
.green { color: #16a34a; }
|
|
294
|
+
.amber { color: #d97706; }
|
|
295
|
+
.red { color: #dc2626; }
|
|
296
|
+
|
|
297
|
+
/* Section */
|
|
298
|
+
.section { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; margin-bottom: 20px; overflow: hidden; }
|
|
299
|
+
.section-title { font-weight: 600; font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; padding: 12px 16px; border-bottom: 1px solid #f1f5f9; }
|
|
300
|
+
|
|
301
|
+
/* Table */
|
|
302
|
+
.table-wrap { overflow-x: auto; }
|
|
303
|
+
table { width: 100%; border-collapse: collapse; }
|
|
304
|
+
th { background: #f8fafc; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; padding: 10px 12px; text-align: left; border-bottom: 1px solid #e2e8f0; }
|
|
305
|
+
td { padding: 10px 12px; border-bottom: 1px solid #f1f5f9; vertical-align: top; }
|
|
306
|
+
tr:last-child td { border-bottom: none; }
|
|
307
|
+
.prompt-cell { max-width: 480px; word-break: break-word; color: #334155; }
|
|
308
|
+
|
|
309
|
+
/* Details */
|
|
310
|
+
.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; }
|
|
311
|
+
.details-btn:hover { background: #f1f5f9; }
|
|
312
|
+
.details-btn.open { color: #3b82f6; border-color: #3b82f6; }
|
|
313
|
+
.details-row > td { padding: 0; background: #f8fafc; }
|
|
314
|
+
.task-details { display: none; padding: 12px 16px; }
|
|
315
|
+
.task-details.visible { display: block; }
|
|
316
|
+
.trial-group-label { font-size: 11px; font-weight: 600; text-transform: uppercase; color: #94a3b8; letter-spacing: 0.05em; margin: 8px 0 4px; }
|
|
317
|
+
|
|
318
|
+
/* Trials */
|
|
319
|
+
.trial { border: 1px solid #e2e8f0; border-radius: 6px; margin-bottom: 8px; overflow: hidden; }
|
|
320
|
+
.trial-header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; font-weight: 500; font-size: 13px; background: #f8fafc; }
|
|
321
|
+
.trial-pass .trial-header { border-left: 3px solid #22c55e; }
|
|
322
|
+
.trial-partial .trial-header { border-left: 3px solid #f59e0b; }
|
|
323
|
+
.trial-fail .trial-header { border-left: 3px solid #ef4444; }
|
|
324
|
+
.trial-error .trial-header { border-left: 3px solid #f59e0b; }
|
|
325
|
+
.trial-assertions { padding: 8px 12px; display: flex; flex-direction: column; gap: 6px; }
|
|
326
|
+
|
|
327
|
+
/* Pills */
|
|
328
|
+
.pill { display: inline-block; font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 99px; letter-spacing: 0.05em; }
|
|
329
|
+
.pill.green { background: #dcfce7; color: #15803d; }
|
|
330
|
+
.pill.red { background: #fee2e2; color: #b91c1c; }
|
|
331
|
+
.pill.amber { background: #fef3c7; color: #92400e; }
|
|
332
|
+
|
|
333
|
+
/* Assertions */
|
|
334
|
+
.assertion { display: flex; gap: 8px; }
|
|
335
|
+
.assert-icon { flex-shrink: 0; font-size: 14px; margin-top: 1px; }
|
|
336
|
+
.assert-pass .assert-icon { color: #16a34a; }
|
|
337
|
+
.assert-fail .assert-icon { color: #dc2626; }
|
|
338
|
+
.assert-body { flex: 1; min-width: 0; }
|
|
339
|
+
.assert-text { font-size: 13px; color: #1e293b; word-break: break-word; }
|
|
340
|
+
.assert-reason { font-size: 12px; color: #64748b; margin-top: 2px; word-break: break-word; }
|
|
341
|
+
.muted { color: #94a3b8; font-size: 13px; }
|
|
342
|
+
</style>
|
|
343
|
+
</head>
|
|
344
|
+
<body>
|
|
345
|
+
<div class="container">
|
|
346
|
+
|
|
347
|
+
<!-- Header -->
|
|
348
|
+
<div class="header">
|
|
349
|
+
<h1>${escapeHtml(skill_name)}</h1>
|
|
350
|
+
<div class="header-meta">
|
|
351
|
+
<span><b>Agent</b> ${escapeHtml(agent)}</span>
|
|
352
|
+
<span><b>Type</b> ${evalType}</span>
|
|
353
|
+
<span><b>Date</b> ${escapeHtml(formattedDate)}</span>
|
|
354
|
+
</div>
|
|
355
|
+
<div class="status-bar ${statusClass}"></div>
|
|
356
|
+
</div>
|
|
357
|
+
|
|
358
|
+
<!-- Metrics Grid -->
|
|
359
|
+
${renderMetricsGrid(report)}
|
|
360
|
+
|
|
361
|
+
<!-- Eval Results Table -->
|
|
362
|
+
<div class="section">
|
|
363
|
+
<div class="section-title">Eval results</div>
|
|
364
|
+
${renderTaskTable(report)}
|
|
365
|
+
</div>
|
|
366
|
+
|
|
367
|
+
</div>
|
|
368
|
+
<script>
|
|
369
|
+
(function () {
|
|
370
|
+
document.querySelectorAll('.details-btn').forEach(function (btn) {
|
|
371
|
+
btn.addEventListener('click', function () {
|
|
372
|
+
const target = document.getElementById(btn.getAttribute('data-target'));
|
|
373
|
+
if (!target) return;
|
|
374
|
+
const isOpen = target.classList.contains('visible');
|
|
375
|
+
target.classList.toggle('visible', !isOpen);
|
|
376
|
+
btn.classList.toggle('open', !isOpen);
|
|
377
|
+
btn.textContent = isOpen ? '▶' : '▼';
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
}());
|
|
381
|
+
</script>
|
|
382
|
+
</body>
|
|
383
|
+
</html>`;
|
|
384
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { Logger } from '../utils/logger.js';
|
|
4
|
+
export class JsonReporter {
|
|
5
|
+
generate(report, runDir) {
|
|
6
|
+
const jsonPath = path.join(runDir, 'summary.json');
|
|
7
|
+
fs.writeFileSync(jsonPath, JSON.stringify(report, null, 2), 'utf-8');
|
|
8
|
+
Logger.write(`\n Report: file://${jsonPath}\n`);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { GeminiCliRunner } from './runner.js';
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import child_process from 'child_process';
|
|
4
|
+
import { Logger } from '../../utils/logger.js';
|
|
5
|
+
export class GeminiCliRunner {
|
|
6
|
+
skillDispatchToolName = 'activate_skill';
|
|
7
|
+
/**
|
|
8
|
+
* Runs the prompt through an isolated gemini instance.
|
|
9
|
+
* Default mode is headless using --approval-mode auto_edit.
|
|
10
|
+
*
|
|
11
|
+
* @param prompt The evaluation prompt text
|
|
12
|
+
* @param cwd Optional execution directory
|
|
13
|
+
* @param onLog Callback to receive real-time logs (from stderr)
|
|
14
|
+
* @param logPath Optional file path to save raw execution logs
|
|
15
|
+
* @returns Raw output from Gemini
|
|
16
|
+
*/
|
|
17
|
+
async runPrompt(prompt, cwd, onLog, logPath, extraArgs = [], timeoutMs) {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
let stdout = '';
|
|
20
|
+
let stderr = '';
|
|
21
|
+
let resolved = false;
|
|
22
|
+
let logStreamEnded = false;
|
|
23
|
+
let terminationReason = 'normal';
|
|
24
|
+
// Matches interactive Y/N prompts only when they appear at the end of a chunk
|
|
25
|
+
// (optionally followed by ": " or whitespace), indicating the process is waiting
|
|
26
|
+
// for user input it will never receive (stdin is closed).
|
|
27
|
+
// Anchoring to end-of-chunk avoids false positives from generated content that
|
|
28
|
+
// happens to contain "[Y/n]" in the middle of a line.
|
|
29
|
+
const INTERACTIVE_PROMPT_RE = /\[[yYnN]\/[yYnN]\]\s*:?\s*$/;
|
|
30
|
+
let timeout;
|
|
31
|
+
function killProcessGroup() {
|
|
32
|
+
if (child.pid) {
|
|
33
|
+
try {
|
|
34
|
+
// Signal the entire process group by using negative PID (Unix only)
|
|
35
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
// Process might have already exited
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function killOnInteractivePrompt(chunk) {
|
|
43
|
+
if (!resolved && INTERACTIVE_PROMPT_RE.test(chunk)) {
|
|
44
|
+
resolved = true;
|
|
45
|
+
if (timeout)
|
|
46
|
+
clearTimeout(timeout);
|
|
47
|
+
killProcessGroup();
|
|
48
|
+
terminationReason = 'interactive-prompt';
|
|
49
|
+
if (logStream) {
|
|
50
|
+
logStream.write('\n\n--- Gemini CLI blocked on interactive prompt — killed ---\n');
|
|
51
|
+
logStream.write(`--- Triggering text: ${JSON.stringify(chunk)} ---\n`);
|
|
52
|
+
if (stderr) {
|
|
53
|
+
logStream.write(`--- Stderr at time of kill ---\n${stderr}\n--- End stderr ---\n`);
|
|
54
|
+
}
|
|
55
|
+
// End the stream here so the log is fully flushed before resolving.
|
|
56
|
+
// logStreamEnded prevents child.on('close') from double-ending it.
|
|
57
|
+
logStreamEnded = true;
|
|
58
|
+
logStream.end(() => {
|
|
59
|
+
resolve({ error: 'Gemini CLI blocked on interactive prompt', raw_output: stderr });
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
resolve({ error: 'Gemini CLI blocked on interactive prompt', raw_output: stderr });
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
// Use -p, --approval-mode auto_edit and --output-format stream-json for headless NDJSON mode.
|
|
70
|
+
const args = ['-p', prompt, '--approval-mode', 'auto_edit', '--output-format', 'stream-json', ...extraArgs];
|
|
71
|
+
const spawnOptions = {
|
|
72
|
+
cwd: cwd,
|
|
73
|
+
env: { ...process.env, FORCE_COLOR: '1' },
|
|
74
|
+
stdio: ['ignore', 'pipe', 'pipe'], // stdin closed → interactive reads get EOF immediately
|
|
75
|
+
detached: true
|
|
76
|
+
};
|
|
77
|
+
const child = child_process.spawn('gemini', args, spawnOptions);
|
|
78
|
+
// Setup log stream if path is provided
|
|
79
|
+
let logStream = null;
|
|
80
|
+
let logStreamDone = true; // Default to true if no logPath
|
|
81
|
+
if (logPath) {
|
|
82
|
+
try {
|
|
83
|
+
logStream = fs.createWriteStream(logPath, { flags: 'a' });
|
|
84
|
+
logStreamDone = false;
|
|
85
|
+
logStream.on('finish', () => {
|
|
86
|
+
logStreamDone = true;
|
|
87
|
+
checkAllDone();
|
|
88
|
+
});
|
|
89
|
+
logStream.write(`--- Gemini CLI Execution Start: ${new Date().toISOString()} ---\n`);
|
|
90
|
+
logStream.write(`Command: gemini ${args.join(' ')}\n\n`);
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
Logger.warn(`Failed to create log file at ${logPath} — debug output will not be saved. Continuing. Reason: ${err}`);
|
|
94
|
+
logStreamDone = true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Safety timeout (configurable via timeoutMs)
|
|
98
|
+
if (timeoutMs && timeoutMs > 0) {
|
|
99
|
+
timeout = setTimeout(() => {
|
|
100
|
+
if (!resolved) {
|
|
101
|
+
resolved = true;
|
|
102
|
+
killProcessGroup();
|
|
103
|
+
terminationReason = 'timeout';
|
|
104
|
+
// logStream is finalized (marker + stderr + end) in child.on('close')
|
|
105
|
+
const timeoutSec = timeoutMs / 1000;
|
|
106
|
+
Logger.error(`\nGemini CLI process timed out after ${timeoutSec} seconds.`);
|
|
107
|
+
resolve({ error: `Process timeout exceeded (${timeoutSec} seconds)`, raw_output: stderr });
|
|
108
|
+
}
|
|
109
|
+
}, timeoutMs);
|
|
110
|
+
}
|
|
111
|
+
// Track completion of streams
|
|
112
|
+
let stdoutDone = false;
|
|
113
|
+
let stderrDone = false;
|
|
114
|
+
let processDone = false;
|
|
115
|
+
function checkAllDone() {
|
|
116
|
+
if (stdoutDone && stderrDone && processDone && logStreamDone && !resolved) {
|
|
117
|
+
resolved = true;
|
|
118
|
+
if (timeout)
|
|
119
|
+
clearTimeout(timeout);
|
|
120
|
+
if (!stdout || stdout.trim() === '') {
|
|
121
|
+
return resolve({ error: 'Empty output from Gemini CLI', raw_output: stderr });
|
|
122
|
+
}
|
|
123
|
+
return resolve({
|
|
124
|
+
response: stdout.trim(),
|
|
125
|
+
raw_output: `${stdout}\n--- STDERR ---\n${stderr}`
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (child.stdout) {
|
|
130
|
+
child.stdout.on('data', (data) => {
|
|
131
|
+
const chunk = data.toString();
|
|
132
|
+
if (killOnInteractivePrompt(chunk))
|
|
133
|
+
return;
|
|
134
|
+
stdout += chunk;
|
|
135
|
+
// Guard against write-after-end: if an interactive prompt on stderr killed the
|
|
136
|
+
// process, logStreamEnded is already true while buffered stdout chunks drain.
|
|
137
|
+
if (logStream && !logStreamEnded)
|
|
138
|
+
logStream.write(chunk);
|
|
139
|
+
});
|
|
140
|
+
child.stdout.on('end', () => {
|
|
141
|
+
stdoutDone = true;
|
|
142
|
+
checkAllDone();
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
stdoutDone = true;
|
|
147
|
+
}
|
|
148
|
+
if (child.stderr) {
|
|
149
|
+
child.stderr.on('data', (data) => {
|
|
150
|
+
const chunk = data.toString();
|
|
151
|
+
if (killOnInteractivePrompt(chunk))
|
|
152
|
+
return;
|
|
153
|
+
stderr += chunk;
|
|
154
|
+
if (onLog) {
|
|
155
|
+
const lines = chunk.split('\n').filter((l) => l.trim() !== '' && !l.startsWith('[DEBUG]'));
|
|
156
|
+
if (lines.length > 0) {
|
|
157
|
+
onLog(lines[lines.length - 1]);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
child.stderr.on('end', () => {
|
|
162
|
+
stderrDone = true;
|
|
163
|
+
checkAllDone();
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
stderrDone = true;
|
|
168
|
+
}
|
|
169
|
+
child.on('error', (err) => {
|
|
170
|
+
if (!resolved) {
|
|
171
|
+
resolved = true;
|
|
172
|
+
if (timeout)
|
|
173
|
+
clearTimeout(timeout);
|
|
174
|
+
terminationReason = 'error';
|
|
175
|
+
// logStream is finalized (marker + stderr + end) in child.on('close')
|
|
176
|
+
Logger.error(`Failed to start gemini CLI. Error: ${err.message}`);
|
|
177
|
+
resolve(null);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
child.on('close', (code) => {
|
|
181
|
+
if (logStream && !logStreamEnded) {
|
|
182
|
+
logStreamEnded = true;
|
|
183
|
+
if (terminationReason === 'timeout') {
|
|
184
|
+
logStream.write('\n\n--- Gemini CLI process timed out ---\n');
|
|
185
|
+
if (stderr)
|
|
186
|
+
logStream.write(`--- Stderr ---\n${stderr}\n--- End Stderr ---\n`);
|
|
187
|
+
}
|
|
188
|
+
else if (terminationReason === 'error') {
|
|
189
|
+
logStream.write('\n\n--- Error starting Gemini CLI ---\n');
|
|
190
|
+
if (stderr)
|
|
191
|
+
logStream.write(`--- Stderr ---\n${stderr}\n--- End Stderr ---\n`);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
// 'normal' exit (interactive-prompt is excluded by the !logStreamEnded guard above)
|
|
195
|
+
logStream.write(`\n\n--- Gemini CLI exited with status ${code} ---\n`);
|
|
196
|
+
if (code !== 0 && stderr) {
|
|
197
|
+
logStream.write(`--- Stderr ---\n${stderr}\n--- End Stderr ---\n`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
logStream.end();
|
|
201
|
+
}
|
|
202
|
+
if (code !== 0 && !resolved) {
|
|
203
|
+
if (onLog)
|
|
204
|
+
onLog(`Exited with status ${code}`);
|
|
205
|
+
}
|
|
206
|
+
processDone = true;
|
|
207
|
+
checkAllDone();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
applyRunnerConfig(evalConfigBaseDir, worktreePath) {
|
|
212
|
+
const src = path.join(evalConfigBaseDir, 'gemini-cli');
|
|
213
|
+
if (!fs.existsSync(src))
|
|
214
|
+
return;
|
|
215
|
+
const dst = path.join(worktreePath, '.gemini');
|
|
216
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
217
|
+
fs.cpSync(src, dst, { recursive: true, force: true });
|
|
218
|
+
}
|
|
219
|
+
async linkSkill(absoluteSkillPath, worktreePath) {
|
|
220
|
+
const skillName = path.basename(absoluteSkillPath);
|
|
221
|
+
const localSkillsDir = path.join(worktreePath, '.agents', 'skills');
|
|
222
|
+
const symlinkPath = path.join(localSkillsDir, skillName);
|
|
223
|
+
if (!fs.existsSync(localSkillsDir)) {
|
|
224
|
+
fs.mkdirSync(localSkillsDir, { recursive: true });
|
|
225
|
+
}
|
|
226
|
+
if (fs.existsSync(symlinkPath)) {
|
|
227
|
+
fs.unlinkSync(symlinkPath);
|
|
228
|
+
}
|
|
229
|
+
fs.symlinkSync(absoluteSkillPath, symlinkPath, 'dir');
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { GeminiCliRunner } from './gemini-cli/index.js';
|
|
2
|
+
export const RUNNER_REGISTRY = {
|
|
3
|
+
'gemini-cli': { Runner: GeminiCliRunner, binary: 'gemini' },
|
|
4
|
+
};
|
|
5
|
+
/** Default agent name used when none is specified on the CLI. */
|
|
6
|
+
export const DEFAULT_AGENT = Object.keys(RUNNER_REGISTRY)[0];
|
|
7
|
+
export class RunnerFactory {
|
|
8
|
+
static create(agent) {
|
|
9
|
+
const entry = RUNNER_REGISTRY[agent];
|
|
10
|
+
if (!entry) {
|
|
11
|
+
const supported = Object.keys(RUNNER_REGISTRY).join(', ');
|
|
12
|
+
throw new Error(`Agent '${agent}' is not supported. Supported agents: ${supported}`);
|
|
13
|
+
}
|
|
14
|
+
return new entry.Runner();
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|