@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,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A semaphore-based pool that limits the number of concurrently running agents.
|
|
3
|
+
* `acquire()` resolves to a `release` callback when a slot is available,
|
|
4
|
+
* blocking if the pool is at capacity. Releases are idempotent; queue is FIFO.
|
|
5
|
+
*/
|
|
6
|
+
export class AgentPool {
|
|
7
|
+
max;
|
|
8
|
+
active = 0;
|
|
9
|
+
queue = [];
|
|
10
|
+
constructor(max) {
|
|
11
|
+
this.max = max;
|
|
12
|
+
}
|
|
13
|
+
acquire() {
|
|
14
|
+
if (this.active < this.max) {
|
|
15
|
+
this.active++;
|
|
16
|
+
return Promise.resolve(this.makeRelease());
|
|
17
|
+
}
|
|
18
|
+
return new Promise(resolve => {
|
|
19
|
+
this.queue.push(() => {
|
|
20
|
+
this.active++;
|
|
21
|
+
resolve(this.makeRelease());
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
makeRelease() {
|
|
26
|
+
let released = false;
|
|
27
|
+
return () => {
|
|
28
|
+
if (released)
|
|
29
|
+
return;
|
|
30
|
+
released = true;
|
|
31
|
+
this.active--;
|
|
32
|
+
this.drain();
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
drain() {
|
|
36
|
+
while (this.active < this.max && this.queue.length > 0) {
|
|
37
|
+
this.queue.shift()();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { ConfigError } from './errors.js';
|
|
4
|
+
const CONFIG_FILE = '.skill-eval.json';
|
|
5
|
+
/**
|
|
6
|
+
* Loads configuration from a `.skill-eval.json` file in the given directory.
|
|
7
|
+
* Returns an empty object if the file does not exist (config is optional).
|
|
8
|
+
*
|
|
9
|
+
* CLI flags always take precedence over config file values — this function
|
|
10
|
+
* only provides defaults for flags not explicitly passed on the command line.
|
|
11
|
+
*
|
|
12
|
+
* @throws ConfigError on malformed JSON or type mismatches.
|
|
13
|
+
*/
|
|
14
|
+
export function loadConfig(cwd) {
|
|
15
|
+
const configPath = path.join(cwd, CONFIG_FILE);
|
|
16
|
+
if (!fs.existsSync(configPath)) {
|
|
17
|
+
return {};
|
|
18
|
+
}
|
|
19
|
+
let raw;
|
|
20
|
+
try {
|
|
21
|
+
raw = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
throw new ConfigError(`Failed to parse ${CONFIG_FILE}: ${err instanceof Error ? err.message : String(err)}`);
|
|
25
|
+
}
|
|
26
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
27
|
+
throw new ConfigError(`${CONFIG_FILE} must be a JSON object.`);
|
|
28
|
+
}
|
|
29
|
+
const config = raw;
|
|
30
|
+
const result = {};
|
|
31
|
+
if ('agent' in config) {
|
|
32
|
+
if (typeof config.agent !== 'string')
|
|
33
|
+
throw new ConfigError(`${CONFIG_FILE}: 'agent' must be a string.`);
|
|
34
|
+
result.agent = config.agent;
|
|
35
|
+
}
|
|
36
|
+
if ('concurrency' in config) {
|
|
37
|
+
if (typeof config.concurrency !== 'number')
|
|
38
|
+
throw new ConfigError(`${CONFIG_FILE}: 'concurrency' must be a number.`);
|
|
39
|
+
result.concurrency = config.concurrency;
|
|
40
|
+
}
|
|
41
|
+
if ('trials' in config) {
|
|
42
|
+
if (typeof config.trials !== 'number')
|
|
43
|
+
throw new ConfigError(`${CONFIG_FILE}: 'trials' must be a number.`);
|
|
44
|
+
result.trials = config.trials;
|
|
45
|
+
}
|
|
46
|
+
if ('report' in config) {
|
|
47
|
+
if (config.report !== 'html' && config.report !== 'json') {
|
|
48
|
+
throw new ConfigError(`${CONFIG_FILE}: 'report' must be 'html' or 'json'.`);
|
|
49
|
+
}
|
|
50
|
+
result.report = config.report;
|
|
51
|
+
}
|
|
52
|
+
if ('skill' in config) {
|
|
53
|
+
if (typeof config.skill !== 'string')
|
|
54
|
+
throw new ConfigError(`${CONFIG_FILE}: 'skill' must be a string.`);
|
|
55
|
+
result.skill = config.skill;
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { executor } from '../utils/exec.js';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import { Logger } from '../utils/logger.js';
|
|
5
|
+
import { ExecutionError } from './errors.js';
|
|
6
|
+
export class EvalEnvironment {
|
|
7
|
+
workspace;
|
|
8
|
+
constructor(options) {
|
|
9
|
+
this.workspace = options.workspace;
|
|
10
|
+
}
|
|
11
|
+
async setup() {
|
|
12
|
+
}
|
|
13
|
+
async teardown() {
|
|
14
|
+
const worktreesDir = path.resolve(this.workspace, '.project-skill-evals', 'worktrees');
|
|
15
|
+
if (!fs.existsSync(worktreesDir))
|
|
16
|
+
return;
|
|
17
|
+
for (const entry of fs.readdirSync(worktreesDir)) {
|
|
18
|
+
this.removeWorktree(path.join(worktreesDir, entry));
|
|
19
|
+
}
|
|
20
|
+
executor.spawnSync('git', ['worktree', 'prune'], { stdio: 'ignore', cwd: this.workspace });
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Creates a temporary git worktree for a specific evaluation.
|
|
24
|
+
* This provides isolation by ensuring each test runs in its own clean copy of the repo.
|
|
25
|
+
*/
|
|
26
|
+
createWorktree(evalId) {
|
|
27
|
+
const worktreePath = path.resolve(this.workspace, '.project-skill-evals', 'worktrees', evalId);
|
|
28
|
+
// Ensure the path is clean before adding a worktree.
|
|
29
|
+
// We try to remove it first in case a previous run crashed.
|
|
30
|
+
executor.spawnSync('git', ['worktree', 'remove', '--force', worktreePath], { stdio: 'ignore', cwd: this.workspace });
|
|
31
|
+
// If git worktree remove failed (e.g. path was never registered, or git
|
|
32
|
+
// metadata is stale), fall back to a physical wipe and a metadata prune so
|
|
33
|
+
// that 'git worktree add' does not exit 128 on a pre-existing path.
|
|
34
|
+
if (fs.existsSync(worktreePath)) {
|
|
35
|
+
fs.rmSync(worktreePath, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
executor.spawnSync('git', ['worktree', 'prune'], { stdio: 'ignore', cwd: this.workspace });
|
|
38
|
+
const child = executor.spawnSync('git', ['worktree', 'add', worktreePath, '-f'], {
|
|
39
|
+
stdio: 'ignore',
|
|
40
|
+
encoding: 'utf-8',
|
|
41
|
+
cwd: this.workspace
|
|
42
|
+
});
|
|
43
|
+
if (child.status !== 0) {
|
|
44
|
+
throw new ExecutionError(`Failed to create git worktree at ${worktreePath}. Process exited with code ${child.status}`);
|
|
45
|
+
}
|
|
46
|
+
return worktreePath;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Removes a previously created git worktree and its associated branch.
|
|
50
|
+
*/
|
|
51
|
+
removeWorktree(worktreePath) {
|
|
52
|
+
const branchName = path.basename(worktreePath);
|
|
53
|
+
const child = executor.spawnSync('git', ['worktree', 'remove', '--force', worktreePath], {
|
|
54
|
+
stdio: 'ignore',
|
|
55
|
+
encoding: 'utf-8',
|
|
56
|
+
cwd: this.workspace
|
|
57
|
+
});
|
|
58
|
+
if (child.status !== 0) {
|
|
59
|
+
// git worktree remove failed (e.g. path already deregistered by a previous prune).
|
|
60
|
+
// Fall back to physical removal and prune stale references.
|
|
61
|
+
try {
|
|
62
|
+
if (fs.existsSync(worktreePath)) {
|
|
63
|
+
fs.rmSync(worktreePath, { recursive: true, force: true });
|
|
64
|
+
}
|
|
65
|
+
executor.spawnSync('git', ['worktree', 'prune'], { stdio: 'ignore', cwd: this.workspace });
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
Logger.warn(`Failed to remove worktree at ${worktreePath}. Process exited with code ${child.status}. Manual cleanup may be required.`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Always try to delete the branch created for this worktree.
|
|
72
|
+
// Wrap in try-catch because the branch might already be gone.
|
|
73
|
+
try {
|
|
74
|
+
executor.spawnSync('git', ['branch', '-D', branchName], {
|
|
75
|
+
stdio: 'ignore',
|
|
76
|
+
cwd: this.workspace
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
// Ignore errors deleting the temporary branch
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export class AppError extends Error {
|
|
2
|
+
message;
|
|
3
|
+
code;
|
|
4
|
+
constructor(message, code = 'GENERIC_ERROR') {
|
|
5
|
+
super(message);
|
|
6
|
+
this.message = message;
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.name = this.constructor.name;
|
|
9
|
+
Object.setPrototypeOf(this, AppError.prototype);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export class ConfigError extends AppError {
|
|
13
|
+
constructor(message) {
|
|
14
|
+
super(message, 'CONFIG_ERROR');
|
|
15
|
+
Object.setPrototypeOf(this, ConfigError.prototype);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export class ExecutionError extends AppError {
|
|
19
|
+
constructor(message) {
|
|
20
|
+
super(message, 'EXECUTION_ERROR');
|
|
21
|
+
Object.setPrototypeOf(this, ExecutionError.prototype);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export class ValidationError extends AppError {
|
|
25
|
+
constructor(message) {
|
|
26
|
+
super(message, 'VALIDATION_ERROR');
|
|
27
|
+
Object.setPrototypeOf(this, ValidationError.prototype);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { executor } from '../utils/exec.js';
|
|
4
|
+
import { EvalEnvironment } from './environment.js';
|
|
5
|
+
import { RunnerFactory } from '../runners/index.js';
|
|
6
|
+
import { TriggerGrader, ModelBasedGrader } from './evaluator.js';
|
|
7
|
+
import { parseStreamResult, parseTokenStats } from '../utils/ndjson.js';
|
|
8
|
+
export class EvalRunner {
|
|
9
|
+
options;
|
|
10
|
+
env;
|
|
11
|
+
runner;
|
|
12
|
+
triggerGrader;
|
|
13
|
+
functionalGrader;
|
|
14
|
+
constructor(options) {
|
|
15
|
+
this.options = options;
|
|
16
|
+
this.env = new EvalEnvironment({ workspace: options.workspace });
|
|
17
|
+
this.runner = RunnerFactory.create(options.agent);
|
|
18
|
+
this.triggerGrader = new TriggerGrader(options.skillName, this.runner.skillDispatchToolName);
|
|
19
|
+
// Inject the same runner for judging so swapping the agent backend works end-to-end
|
|
20
|
+
this.functionalGrader = new ModelBasedGrader(options.skillName, this.runner);
|
|
21
|
+
}
|
|
22
|
+
async runTriggerTask(task, index, trialId, uiCtx, attempt = 0) {
|
|
23
|
+
const logFileName = `task_${task.id}_trial_${trialId}.log`;
|
|
24
|
+
const logPath = this.options.debug ? path.join(this.options.runDir, logFileName) : undefined;
|
|
25
|
+
let worktreePath;
|
|
26
|
+
let transcript = null;
|
|
27
|
+
let durationMs;
|
|
28
|
+
const worktreeId = attempt > 0 ? `task-${task.id}-trial-${trialId}-r${attempt}` : `task-${task.id}-trial-${trialId}`;
|
|
29
|
+
if (attempt > 0) {
|
|
30
|
+
const prevId = attempt === 1
|
|
31
|
+
? `task-${task.id}-trial-${trialId}`
|
|
32
|
+
: `task-${task.id}-trial-${trialId}-r${attempt - 1}`;
|
|
33
|
+
this.env.removeWorktree(path.resolve(this.options.workspace, '.project-skill-evals', 'worktrees', prevId));
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
uiCtx.updateLog('Setting up…');
|
|
37
|
+
worktreePath = this.env.createWorktree(worktreeId);
|
|
38
|
+
await this.runner.linkSkill(path.resolve(this.options.workspace, this.options.skillPath), worktreePath);
|
|
39
|
+
this.runner.applyRunnerConfig(path.resolve(this.options.workspace, this.options.skillPath, 'evals', 'config'), worktreePath);
|
|
40
|
+
uiCtx.updateLog('Executing prompt…');
|
|
41
|
+
const startMs = Date.now();
|
|
42
|
+
transcript = await this.runner.runPrompt(task.prompt, worktreePath, undefined, logPath, undefined, this.options.timeoutMs);
|
|
43
|
+
durationMs = Date.now() - startMs;
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
if (worktreePath) {
|
|
47
|
+
this.env.removeWorktree(worktreePath);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Propagate stream-json errors: when the agent fails, Gemini CLI still
|
|
51
|
+
// writes a {"type":"result","status":"error",...} event to stdout so transcript.error
|
|
52
|
+
// is never set by the runner. Parse it here so the grading path is skipped correctly.
|
|
53
|
+
if (transcript && !transcript.error) {
|
|
54
|
+
const streamResult = parseStreamResult(transcript.response || '');
|
|
55
|
+
if (streamResult && 'error' in streamResult) {
|
|
56
|
+
transcript.error = streamResult.error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Extract token stats from the agent's NDJSON stream (never from the judge).
|
|
60
|
+
const tokenStats = transcript
|
|
61
|
+
? parseTokenStats(transcript.response || '') ?? undefined
|
|
62
|
+
: undefined;
|
|
63
|
+
let triggered = false;
|
|
64
|
+
const assertionResults = [];
|
|
65
|
+
if (transcript && !transcript.error) {
|
|
66
|
+
uiCtx.updateLog('Grading…');
|
|
67
|
+
triggered = this.triggerGrader.gradeTrigger(transcript);
|
|
68
|
+
assertionResults.push({
|
|
69
|
+
assertion: 'Skill was triggered',
|
|
70
|
+
passed: triggered,
|
|
71
|
+
reason: triggered ? 'Detected skill activation in transcript' : 'No skill activation detected in transcript',
|
|
72
|
+
graderType: 'programmatic'
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
const errorMsg = transcript?.error || 'Error: No transcript was produced';
|
|
77
|
+
assertionResults.push({
|
|
78
|
+
assertion: 'Skill was triggered',
|
|
79
|
+
passed: false,
|
|
80
|
+
reason: errorMsg,
|
|
81
|
+
graderType: 'programmatic'
|
|
82
|
+
});
|
|
83
|
+
return {
|
|
84
|
+
id: trialId,
|
|
85
|
+
transcript: transcript || { error: 'No transcript produced' },
|
|
86
|
+
assertionResults,
|
|
87
|
+
trialPassed: false,
|
|
88
|
+
isError: true,
|
|
89
|
+
tokenStats,
|
|
90
|
+
durationMs
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
id: trialId,
|
|
95
|
+
transcript: transcript || { error: 'No transcript produced' },
|
|
96
|
+
assertionResults: assertionResults,
|
|
97
|
+
trialPassed: triggered,
|
|
98
|
+
tokenStats,
|
|
99
|
+
durationMs
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
async runFunctionalTask(task, index, trialId, uiCtx, attempt = 0) {
|
|
103
|
+
const skillDisabled = this.options.isBaseline;
|
|
104
|
+
const evalModeLabel = skillDisabled ? 'without-skill' : 'with-skill';
|
|
105
|
+
const promptToUse = skillDisabled
|
|
106
|
+
? `${task.prompt}\n\nIMPORTANT: For this task, you MUST NOT use the '${this.options.skillName}' skill/tool, even if it appears available.`
|
|
107
|
+
: `${task.prompt}\n\nIMPORTANT: You must use the '${this.options.skillName}' skill/tool to solve this task.`;
|
|
108
|
+
const logFileName = `task_${task.id}_${evalModeLabel}_trial_${trialId}.log`;
|
|
109
|
+
const logPath = this.options.debug ? path.join(this.options.runDir, logFileName) : undefined;
|
|
110
|
+
let worktreePath;
|
|
111
|
+
let assertionResults = [];
|
|
112
|
+
let trialPassed = false;
|
|
113
|
+
let transcript = null;
|
|
114
|
+
let durationMs;
|
|
115
|
+
const worktreeId = attempt > 0 ? `task-${task.id}-${evalModeLabel}-trial-${trialId}-r${attempt}` : `task-${task.id}-${evalModeLabel}-trial-${trialId}`;
|
|
116
|
+
if (attempt > 0) {
|
|
117
|
+
const prevId = attempt === 1
|
|
118
|
+
? `task-${task.id}-${evalModeLabel}-trial-${trialId}`
|
|
119
|
+
: `task-${task.id}-${evalModeLabel}-trial-${trialId}-r${attempt - 1}`;
|
|
120
|
+
this.env.removeWorktree(path.resolve(this.options.workspace, '.project-skill-evals', 'worktrees', prevId));
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
uiCtx.updateLog('Setting up…');
|
|
124
|
+
worktreePath = this.env.createWorktree(worktreeId);
|
|
125
|
+
if (!skillDisabled) {
|
|
126
|
+
await this.runner.linkSkill(path.resolve(this.options.workspace, this.options.skillPath), worktreePath);
|
|
127
|
+
}
|
|
128
|
+
this.runner.applyRunnerConfig(path.resolve(this.options.workspace, this.options.skillPath, 'evals', 'config'), worktreePath);
|
|
129
|
+
uiCtx.updateLog('Executing prompt…');
|
|
130
|
+
if (logPath)
|
|
131
|
+
fs.appendFileSync(logPath, `\n# SECTION: ${evalModeLabel.toUpperCase()} AGENT RUN\n`);
|
|
132
|
+
const startMs = Date.now();
|
|
133
|
+
transcript = await this.runner.runPrompt(promptToUse, worktreePath, undefined, logPath, undefined, this.options.timeoutMs);
|
|
134
|
+
durationMs = Date.now() - startMs;
|
|
135
|
+
// Propagate stream-json errors: when the agent fails (e.g. quota), Gemini CLI still
|
|
136
|
+
// writes a {"type":"result","status":"error",...} event to stdout so transcript.error
|
|
137
|
+
// is never set by the runner. Parse it here so the grading path is skipped correctly.
|
|
138
|
+
if (transcript && !transcript.error) {
|
|
139
|
+
const streamResult = parseStreamResult(transcript.response || '');
|
|
140
|
+
if (streamResult && 'error' in streamResult) {
|
|
141
|
+
transcript.error = streamResult.error;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// Extract token stats from the agent's NDJSON stream (never from the judge).
|
|
145
|
+
const tokenStats = transcript
|
|
146
|
+
? parseTokenStats(transcript.response || '') ?? undefined
|
|
147
|
+
: undefined;
|
|
148
|
+
if (transcript && !transcript.error) {
|
|
149
|
+
if (skillDisabled && this.triggerGrader.detectSkillAttempt(transcript)) {
|
|
150
|
+
return {
|
|
151
|
+
id: trialId,
|
|
152
|
+
transcript,
|
|
153
|
+
assertionResults: [{
|
|
154
|
+
assertion: 'Baseline must not invoke the restricted skill',
|
|
155
|
+
passed: false,
|
|
156
|
+
reason: `Invalid Without Skill: '${this.options.skillName}' activation detected during without-skill run`,
|
|
157
|
+
graderType: 'programmatic'
|
|
158
|
+
}],
|
|
159
|
+
trialPassed: false,
|
|
160
|
+
tokenStats,
|
|
161
|
+
durationMs
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (!skillDisabled && !this.triggerGrader.gradeTrigger(transcript)) {
|
|
165
|
+
return {
|
|
166
|
+
id: trialId,
|
|
167
|
+
transcript,
|
|
168
|
+
assertionResults: [{
|
|
169
|
+
assertion: 'Target pass must invoke the skill',
|
|
170
|
+
passed: false,
|
|
171
|
+
reason: `Invalid With Skill: '${this.options.skillName}' was not successfully activated`,
|
|
172
|
+
graderType: 'programmatic'
|
|
173
|
+
}],
|
|
174
|
+
trialPassed: false,
|
|
175
|
+
tokenStats,
|
|
176
|
+
durationMs
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
let context = 'No changes detected or git not available.';
|
|
180
|
+
try {
|
|
181
|
+
if (worktreePath) {
|
|
182
|
+
const diff = executor.execSync('git diff HEAD', { encoding: 'utf-8', cwd: worktreePath });
|
|
183
|
+
const untracked = executor.execSync('git ls-files --others --exclude-standard', { encoding: 'utf-8', cwd: worktreePath });
|
|
184
|
+
if (diff || untracked) {
|
|
185
|
+
context = `[DIFF]\n${diff}\n\n[UNTRACKED FILES]\n${untracked}`;
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
context = 'No changes detected (clean workspace).';
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch (e) { }
|
|
193
|
+
if (task.assertions && task.assertions.length > 0) {
|
|
194
|
+
uiCtx.updateLog('Grading…');
|
|
195
|
+
if (logPath)
|
|
196
|
+
fs.appendFileSync(logPath, `\n# SECTION: ${evalModeLabel.toUpperCase()} JUDGE RUN\n`);
|
|
197
|
+
// Use the already-parsed stream result to get clean text for the judge.
|
|
198
|
+
const streamResult = parseStreamResult(transcript.response || '');
|
|
199
|
+
const streamText = streamResult && 'response' in streamResult ? streamResult.response : '';
|
|
200
|
+
const gradingTranscript = streamText ? { ...transcript, response: streamText } : transcript;
|
|
201
|
+
// Retry only the judge on infrastructure failures (timeout, interactive prompt, etc.).
|
|
202
|
+
// The agent has already run successfully — no need to re-run it.
|
|
203
|
+
const MAX_JUDGE_RETRIES = 2;
|
|
204
|
+
const judgeDelayMs = this.options.judgeRetryDelayMs ?? 1000;
|
|
205
|
+
let judgeErrorAfterAllRetries = null;
|
|
206
|
+
for (let judgeAttempt = 0; judgeAttempt <= MAX_JUDGE_RETRIES; judgeAttempt++) {
|
|
207
|
+
if (judgeAttempt > 0) {
|
|
208
|
+
uiCtx.updateLog(`Judge error, retrying (${judgeAttempt}/${MAX_JUDGE_RETRIES})…`);
|
|
209
|
+
await new Promise(r => setTimeout(r, judgeDelayMs * Math.pow(2, judgeAttempt - 1)));
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
assertionResults = await this.functionalGrader.gradeModelBased(task.prompt, gradingTranscript, task.assertions, context, (log) => { uiCtx.updateLog(`Grading: ${log}`); }, logPath, worktreePath);
|
|
213
|
+
judgeErrorAfterAllRetries = null;
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
catch (e) {
|
|
217
|
+
judgeErrorAfterAllRetries = e instanceof Error ? e : new Error(String(e));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (judgeErrorAfterAllRetries) {
|
|
221
|
+
// Judge exhausted all retries — return an inconclusive failure.
|
|
222
|
+
// isError is intentionally NOT set so the outer withRetry does not
|
|
223
|
+
// re-run the expensive agent for a judge infrastructure issue.
|
|
224
|
+
const reason = judgeErrorAfterAllRetries.message;
|
|
225
|
+
return {
|
|
226
|
+
id: trialId,
|
|
227
|
+
transcript: transcript || { error: 'No transcript produced' },
|
|
228
|
+
assertionResults: task.assertions.map(a => ({
|
|
229
|
+
assertion: a, passed: false, reason, graderType: 'model-based'
|
|
230
|
+
})),
|
|
231
|
+
trialPassed: false,
|
|
232
|
+
tokenStats,
|
|
233
|
+
durationMs
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
trialPassed = assertionResults.every(r => r.passed);
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
trialPassed = true;
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
id: trialId,
|
|
243
|
+
transcript: transcript || { error: 'No transcript produced' },
|
|
244
|
+
assertionResults: assertionResults,
|
|
245
|
+
trialPassed,
|
|
246
|
+
tokenStats,
|
|
247
|
+
durationMs
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
const errorMsg = transcript?.error || 'Error: No transcript was produced';
|
|
252
|
+
if (task.assertions) {
|
|
253
|
+
assertionResults = task.assertions.map(a => ({
|
|
254
|
+
assertion: a,
|
|
255
|
+
passed: false,
|
|
256
|
+
reason: `Agent execution failed: ${errorMsg}`,
|
|
257
|
+
graderType: 'model-based'
|
|
258
|
+
}));
|
|
259
|
+
}
|
|
260
|
+
// isError return — finally still runs cleanup
|
|
261
|
+
return {
|
|
262
|
+
id: trialId,
|
|
263
|
+
transcript: { error: transcript?.error || 'No transcript produced' },
|
|
264
|
+
assertionResults,
|
|
265
|
+
trialPassed: false,
|
|
266
|
+
isError: true,
|
|
267
|
+
tokenStats,
|
|
268
|
+
durationMs
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
catch (e) {
|
|
273
|
+
const errorMsg = e instanceof Error ? e.message : String(e);
|
|
274
|
+
if (task.assertions) {
|
|
275
|
+
assertionResults = task.assertions.map(a => ({
|
|
276
|
+
assertion: a,
|
|
277
|
+
passed: false,
|
|
278
|
+
reason: `Execution failed: ${errorMsg}`,
|
|
279
|
+
graderType: 'model-based'
|
|
280
|
+
}));
|
|
281
|
+
}
|
|
282
|
+
// isError return — finally still runs cleanup
|
|
283
|
+
return {
|
|
284
|
+
id: trialId,
|
|
285
|
+
transcript: { error: errorMsg },
|
|
286
|
+
assertionResults,
|
|
287
|
+
trialPassed: false,
|
|
288
|
+
isError: true
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
if (worktreePath) {
|
|
293
|
+
this.env.removeWorktree(worktreePath);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// Unreachable: all paths above return explicitly.
|
|
297
|
+
// This satisfies TypeScript's control-flow analysis.
|
|
298
|
+
return {
|
|
299
|
+
id: trialId,
|
|
300
|
+
transcript: { error: 'No transcript produced' },
|
|
301
|
+
assertionResults,
|
|
302
|
+
trialPassed: false,
|
|
303
|
+
isError: true
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
}
|