praxis-agent 0.47.0 → 0.48.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/README.md +4 -2
- package/dist/cli-runtime.js +8 -0
- package/dist/evals/project-eval-comparison.d.ts +76 -0
- package/dist/evals/project-eval-comparison.js +513 -0
- package/dist/evals/project-eval-runner.d.ts +8 -0
- package/dist/evals/project-eval-runner.js +34 -0
- package/dist/evals/project-eval.d.ts +23 -1
- package/dist/evals/project-eval.js +25 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -103,7 +103,9 @@ troubleshooting. Run `praxis --help` for the authoritative command surface.
|
|
|
103
103
|
- **Outcome-driven evaluation** — `praxis eval <target>` runs contained cases
|
|
104
104
|
in isolated workspaces, requires explicit verifier authorization, and writes
|
|
105
105
|
versioned artifacts locally; usage and cost remain explicitly available or
|
|
106
|
-
unknown.
|
|
106
|
+
unknown. Separate runs can be compared with `praxis eval compare`; unknown
|
|
107
|
+
token/cost evidence produces null deltas, while the gate requires no pass-rate
|
|
108
|
+
or safety-rate regression and rejects incomplete safety evidence.
|
|
107
109
|
- **Local agent runtime** — C+ Quiet Operator responsive TUI with a linear
|
|
108
110
|
`❯` user / `⏺` assistant conversation, `✻` thinking activity, and `!` shell
|
|
109
111
|
composer grammar, compact stable tool rows, responsive density,
|
|
@@ -299,7 +301,7 @@ normal/low-capability full-frame p95 budgets of `<16.7/<33 ms`.
|
|
|
299
301
|
`npm run test:coverage` measures all production code under `src/**` with V8 and
|
|
300
302
|
enforces global floors of 79% statements, 70% branches, 85% functions, and 81% lines,
|
|
301
303
|
and rejects any production runtime module with zero covered statements (while allowing
|
|
302
|
-
type-only modules). `npm run test:fixtures` executes the
|
|
304
|
+
type-only modules). `npm run test:fixtures` executes the 68-behavior native contract; 60 behaviors
|
|
303
305
|
are qualified and 8 are explicitly excluded. `npm run verify:fixture-contracts`
|
|
304
306
|
performs the structural check and is part of `npm run check`.
|
|
305
307
|
`npm run test:core-completion` is retained as a compatibility alias for
|
package/dist/cli-runtime.js
CHANGED
|
@@ -71,6 +71,7 @@ import { describeClaudePlugin, initClaudePlugin, installClaudePlugin, loadClaude
|
|
|
71
71
|
import { addClaudeMarketplace, disableAllNativePlugins, installClaudeMarketplacePlugin, listClaudeMarketplaceAvailablePlugins, listNativePluginRecords, readClaudeKnownMarketplaces, removeClaudeMarketplace, setNativePluginEnabled, saveClaudePluginConfig, uninstallNativePlugin, updateClaudeMarketplace, updateNativePlugin, validateClaudeMarketplace, } from './plugins/claude-plugin-marketplace.js';
|
|
72
72
|
import { executeClaudePluginEvalCommand, PLUGIN_EVAL_HELP, } from './plugins/claude-plugin-eval.js';
|
|
73
73
|
import { executeProjectEvalCommand, PROJECT_EVAL_HELP, } from './evals/project-eval.js';
|
|
74
|
+
import { PROJECT_EVAL_COMPARE_HELP } from './evals/project-eval-comparison.js';
|
|
74
75
|
import { CLAUDE_PLUGIN_PRUNE_HELP, CLAUDE_PLUGIN_TAG_HELP, executeClaudePluginPrune, planClaudePluginPrune, tagClaudePlugin, } from './plugins/claude-plugin-maintenance.js';
|
|
75
76
|
import { formatDoctorReport, runDoctor } from './maintenance/doctor.js';
|
|
76
77
|
import { runSelfUpdate, } from './maintenance/self-update.js';
|
|
@@ -4983,6 +4984,13 @@ async function execute(argv, io, dependencies, signal) {
|
|
|
4983
4984
|
}
|
|
4984
4985
|
: { args: [...argv] };
|
|
4985
4986
|
if (special.args[0] === 'eval') {
|
|
4987
|
+
if (special.args[1] === 'compare' &&
|
|
4988
|
+
special.args
|
|
4989
|
+
.slice(2)
|
|
4990
|
+
.some((value) => value === '-h' || value === '--help')) {
|
|
4991
|
+
io.stdout(PROJECT_EVAL_COMPARE_HELP);
|
|
4992
|
+
return 0;
|
|
4993
|
+
}
|
|
4986
4994
|
if (special.args
|
|
4987
4995
|
.slice(1)
|
|
4988
4996
|
.some((value) => value === '-h' || value === '--help')) {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ProjectEvalAggregate } from './project-eval.js';
|
|
2
|
+
export declare const PROJECT_EVAL_COMPARE_HELP = "Usage: praxis eval compare [options]\n\nCompare two completed project evaluation aggregate artifacts.\n\nOptions:\n --baseline <aggregate-result.json> Baseline aggregate artifact\n --baseline-name <name> Name shown for the baseline\n --candidate <aggregate-result.json> Candidate aggregate artifact\n --candidate-name <name> Name shown for the candidate\n --output-dir <dir> Write comparison-result.json here\n --json Print exactly one comparison JSON value\n -h, --help Display help";
|
|
3
|
+
export interface ProjectEvalCompareOptions {
|
|
4
|
+
baseline?: string;
|
|
5
|
+
baselineName?: string;
|
|
6
|
+
candidate?: string;
|
|
7
|
+
candidateName?: string;
|
|
8
|
+
outputDir?: string;
|
|
9
|
+
json: boolean;
|
|
10
|
+
help?: true;
|
|
11
|
+
}
|
|
12
|
+
interface LoadedAggregate {
|
|
13
|
+
aggregate: ProjectEvalAggregate;
|
|
14
|
+
sourcePath: string;
|
|
15
|
+
safetyKnown: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface ProjectEvalComparisonMetric<T = number | null> {
|
|
18
|
+
baseline: T;
|
|
19
|
+
candidate: T;
|
|
20
|
+
delta: T;
|
|
21
|
+
}
|
|
22
|
+
export interface ProjectEvalComparisonResult {
|
|
23
|
+
schema_version: '1.0';
|
|
24
|
+
baseline: {
|
|
25
|
+
name: string;
|
|
26
|
+
source_path: string;
|
|
27
|
+
version: string;
|
|
28
|
+
model: string | null;
|
|
29
|
+
};
|
|
30
|
+
candidate: {
|
|
31
|
+
name: string;
|
|
32
|
+
source_path: string;
|
|
33
|
+
version: string;
|
|
34
|
+
model: string | null;
|
|
35
|
+
};
|
|
36
|
+
comparable_run_count: number;
|
|
37
|
+
passed: boolean;
|
|
38
|
+
regressions: readonly {
|
|
39
|
+
case: string;
|
|
40
|
+
run: number;
|
|
41
|
+
baseline_passed: boolean;
|
|
42
|
+
candidate_passed: boolean;
|
|
43
|
+
}[];
|
|
44
|
+
metrics: {
|
|
45
|
+
pass_rate: ProjectEvalComparisonMetric<number>;
|
|
46
|
+
safety_pass_rate: ProjectEvalComparisonMetric<number | null>;
|
|
47
|
+
average_turns: ProjectEvalComparisonMetric<number>;
|
|
48
|
+
input_tokens: ProjectEvalComparisonMetric<number | null>;
|
|
49
|
+
output_tokens: ProjectEvalComparisonMetric<number | null>;
|
|
50
|
+
cache_read_input_tokens: ProjectEvalComparisonMetric<number | null>;
|
|
51
|
+
cache_creation_input_tokens: ProjectEvalComparisonMetric<number | null>;
|
|
52
|
+
known_cost_total_usd: ProjectEvalComparisonMetric<number | null>;
|
|
53
|
+
average_duration_ms: ProjectEvalComparisonMetric<number>;
|
|
54
|
+
permission_decisions: {
|
|
55
|
+
allow: ProjectEvalComparisonMetric<number | null>;
|
|
56
|
+
ask: ProjectEvalComparisonMetric<number | null>;
|
|
57
|
+
deny: ProjectEvalComparisonMetric<number | null>;
|
|
58
|
+
};
|
|
59
|
+
tool_errors: ProjectEvalComparisonMetric<number | null>;
|
|
60
|
+
retries: ProjectEvalComparisonMetric<number | null>;
|
|
61
|
+
terminations: {
|
|
62
|
+
completed: ProjectEvalComparisonMetric<number>;
|
|
63
|
+
timeout: ProjectEvalComparisonMetric<number>;
|
|
64
|
+
interrupted: ProjectEvalComparisonMetric<number>;
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export declare function parseProjectEvalCompareOptions(argv: readonly string[]): ProjectEvalCompareOptions;
|
|
69
|
+
export declare function loadProjectEvalAggregate(inputPath: string, callerCwd?: string): Promise<LoadedAggregate>;
|
|
70
|
+
export declare function compareProjectEvalAggregates(baseline: LoadedAggregate, candidate: LoadedAggregate, baselineName: string, candidateName: string): ProjectEvalComparisonResult;
|
|
71
|
+
export declare function executeProjectEvalCompareCommand(argv: readonly string[], io: {
|
|
72
|
+
stdout(message: string): void;
|
|
73
|
+
stderr(message: string): void;
|
|
74
|
+
}, callerCwd?: string, signal?: AbortSignal): Promise<number>;
|
|
75
|
+
export {};
|
|
76
|
+
//# sourceMappingURL=project-eval-comparison.d.ts.map
|
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import { mkdir, lstat, readFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import { writeFileAtomically } from '../platform/atomic-write.js';
|
|
4
|
+
const MAX_AGGREGATE_BYTES = 8 * 1024 * 1024;
|
|
5
|
+
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
|
|
6
|
+
export const PROJECT_EVAL_COMPARE_HELP = `Usage: praxis eval compare [options]
|
|
7
|
+
|
|
8
|
+
Compare two completed project evaluation aggregate artifacts.
|
|
9
|
+
|
|
10
|
+
Options:
|
|
11
|
+
--baseline <aggregate-result.json> Baseline aggregate artifact
|
|
12
|
+
--baseline-name <name> Name shown for the baseline
|
|
13
|
+
--candidate <aggregate-result.json> Candidate aggregate artifact
|
|
14
|
+
--candidate-name <name> Name shown for the candidate
|
|
15
|
+
--output-dir <dir> Write comparison-result.json here
|
|
16
|
+
--json Print exactly one comparison JSON value
|
|
17
|
+
-h, --help Display help`;
|
|
18
|
+
function valueAt(argv, index, option) {
|
|
19
|
+
const value = argv[index + 1];
|
|
20
|
+
if (!value || value.startsWith('-'))
|
|
21
|
+
throw new Error(`${option} requires a value`);
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
export function parseProjectEvalCompareOptions(argv) {
|
|
25
|
+
const options = { json: false };
|
|
26
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
27
|
+
const value = argv[index];
|
|
28
|
+
if (!value)
|
|
29
|
+
continue;
|
|
30
|
+
if (value === '-h' || value === '--help')
|
|
31
|
+
return { ...options, help: true };
|
|
32
|
+
if (value === '--json')
|
|
33
|
+
options.json = true;
|
|
34
|
+
else if (value === '--baseline' ||
|
|
35
|
+
value === '--baseline-name' ||
|
|
36
|
+
value === '--candidate' ||
|
|
37
|
+
value === '--candidate-name' ||
|
|
38
|
+
value === '--output-dir') {
|
|
39
|
+
const selected = valueAt(argv, index, value);
|
|
40
|
+
index += 1;
|
|
41
|
+
if (value === '--baseline')
|
|
42
|
+
options.baseline = selected;
|
|
43
|
+
else if (value === '--baseline-name')
|
|
44
|
+
options.baselineName = selected;
|
|
45
|
+
else if (value === '--candidate')
|
|
46
|
+
options.candidate = selected;
|
|
47
|
+
else if (value === '--candidate-name')
|
|
48
|
+
options.candidateName = selected;
|
|
49
|
+
else
|
|
50
|
+
options.outputDir = selected;
|
|
51
|
+
}
|
|
52
|
+
else if (value.startsWith('-')) {
|
|
53
|
+
throw new Error(`Unknown eval compare option: ${value}`);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
throw new Error('eval compare accepts no positional operands');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (options.help)
|
|
60
|
+
return options;
|
|
61
|
+
for (const [option, selected] of [
|
|
62
|
+
['--baseline', options.baseline],
|
|
63
|
+
['--baseline-name', options.baselineName],
|
|
64
|
+
['--candidate', options.candidate],
|
|
65
|
+
['--candidate-name', options.candidateName],
|
|
66
|
+
]) {
|
|
67
|
+
if (!selected)
|
|
68
|
+
throw new Error(`${option} is required`);
|
|
69
|
+
}
|
|
70
|
+
if (!IDENTIFIER.test(options.baselineName ?? ''))
|
|
71
|
+
throw new Error('--baseline-name is not a safe eval identifier');
|
|
72
|
+
if (!IDENTIFIER.test(options.candidateName ?? ''))
|
|
73
|
+
throw new Error('--candidate-name is not a safe eval identifier');
|
|
74
|
+
return options;
|
|
75
|
+
}
|
|
76
|
+
function fail(path, message) {
|
|
77
|
+
throw new Error(`Invalid aggregate ${path}: ${message}`);
|
|
78
|
+
}
|
|
79
|
+
function stringField(value, path, nullable = false) {
|
|
80
|
+
if (nullable && value === null)
|
|
81
|
+
return null;
|
|
82
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 4096)
|
|
83
|
+
fail(path, 'expected a bounded non-empty string');
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
function boolField(value, path) {
|
|
87
|
+
if (typeof value !== 'boolean')
|
|
88
|
+
fail(path, 'expected a boolean');
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
function numberField(value, path, integer = false) {
|
|
92
|
+
if (typeof value !== 'number' ||
|
|
93
|
+
!Number.isFinite(value) ||
|
|
94
|
+
value < 0 ||
|
|
95
|
+
(integer && !Number.isSafeInteger(value)))
|
|
96
|
+
fail(path, 'expected a finite nonnegative number');
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
function nullableNumber(value, path) {
|
|
100
|
+
if (value === null)
|
|
101
|
+
return null;
|
|
102
|
+
return numberField(value, path);
|
|
103
|
+
}
|
|
104
|
+
function objectField(value, path) {
|
|
105
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
106
|
+
fail(path, 'expected an object');
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
function optionalGroup(value, keys, path) {
|
|
110
|
+
const present = keys.filter((key) => value[key] !== undefined);
|
|
111
|
+
if (present.length !== 0 && present.length !== keys.length)
|
|
112
|
+
fail(path, `fields must be all present or all absent: ${keys.join(', ')}`);
|
|
113
|
+
return present.length === keys.length;
|
|
114
|
+
}
|
|
115
|
+
function usageField(value, path) {
|
|
116
|
+
if (value === null)
|
|
117
|
+
return null;
|
|
118
|
+
const usage = objectField(value, path);
|
|
119
|
+
for (const key of [
|
|
120
|
+
'inputTokens',
|
|
121
|
+
'outputTokens',
|
|
122
|
+
'cacheReadInputTokens',
|
|
123
|
+
'cacheCreationInputTokens',
|
|
124
|
+
'webSearchRequests',
|
|
125
|
+
]) {
|
|
126
|
+
if (usage[key] !== undefined)
|
|
127
|
+
numberField(usage[key], `${path}.${key}`, true);
|
|
128
|
+
}
|
|
129
|
+
if (usage.inputTokens === undefined || usage.outputTokens === undefined)
|
|
130
|
+
fail(path, 'inputTokens and outputTokens are required');
|
|
131
|
+
return usage;
|
|
132
|
+
}
|
|
133
|
+
function validateRun(value, index) {
|
|
134
|
+
const path = `runs[${index}]`;
|
|
135
|
+
const run = objectField(value, path);
|
|
136
|
+
stringField(run.case, `${path}.case`);
|
|
137
|
+
if (!IDENTIFIER.test(run.case))
|
|
138
|
+
fail(`${path}.case`, 'unsafe case name');
|
|
139
|
+
const runNumber = numberField(run.run, `${path}.run`, true);
|
|
140
|
+
if (runNumber < 1)
|
|
141
|
+
fail(`${path}.run`, 'must be positive');
|
|
142
|
+
stringField(run.model, `${path}.model`, true);
|
|
143
|
+
boolField(run.passed, `${path}.passed`);
|
|
144
|
+
if (run.score !== 0 && run.score !== 1)
|
|
145
|
+
fail(`${path}.score`, 'must be 0 or 1');
|
|
146
|
+
if (run.score !== (run.passed ? 1 : 0))
|
|
147
|
+
fail(`${path}.score`, 'does not match passed');
|
|
148
|
+
numberField(run.turns, `${path}.turns`, true);
|
|
149
|
+
usageField(run.usage, `${path}.usage`);
|
|
150
|
+
nullableNumber(run.cost_usd, `${path}.cost_usd`);
|
|
151
|
+
boolField(run.cost_known, `${path}.cost_known`);
|
|
152
|
+
if (run.cost_known !== (run.cost_usd !== null))
|
|
153
|
+
fail(`${path}.cost_known`, 'does not match cost_usd');
|
|
154
|
+
numberField(run.duration_ms, `${path}.duration_ms`);
|
|
155
|
+
if (run.termination !== null &&
|
|
156
|
+
run.termination !== 'timeout' &&
|
|
157
|
+
run.termination !== 'interrupted')
|
|
158
|
+
fail(`${path}.termination`, 'invalid termination');
|
|
159
|
+
stringField(run.error, `${path}.error`, true);
|
|
160
|
+
stringField(run.artifact_dir, `${path}.artifact_dir`);
|
|
161
|
+
const evidenceKnown = optionalGroup(run, ['safety_passed', 'permission_decisions', 'tool_errors', 'retries'], path);
|
|
162
|
+
if (evidenceKnown) {
|
|
163
|
+
boolField(run.safety_passed, `${path}.safety_passed`);
|
|
164
|
+
const permissions = objectField(run.permission_decisions, `${path}.permission_decisions`);
|
|
165
|
+
for (const key of ['allow', 'ask', 'deny'])
|
|
166
|
+
numberField(permissions[key], `${path}.permission_decisions.${key}`, true);
|
|
167
|
+
numberField(run.tool_errors, `${path}.tool_errors`, true);
|
|
168
|
+
numberField(run.retries, `${path}.retries`, true);
|
|
169
|
+
}
|
|
170
|
+
return run;
|
|
171
|
+
}
|
|
172
|
+
export async function loadProjectEvalAggregate(inputPath, callerCwd = process.cwd()) {
|
|
173
|
+
const sourcePath = resolve(callerCwd, inputPath);
|
|
174
|
+
const info = await lstat(sourcePath);
|
|
175
|
+
if (info.isSymbolicLink())
|
|
176
|
+
throw new Error(`Aggregate path contains symlink: ${sourcePath}`);
|
|
177
|
+
if (!info.isFile())
|
|
178
|
+
throw new Error(`Aggregate path is not a regular file: ${sourcePath}`);
|
|
179
|
+
if (info.size > MAX_AGGREGATE_BYTES)
|
|
180
|
+
throw new Error(`Aggregate exceeds 8 MiB: ${sourcePath}`);
|
|
181
|
+
const content = await readFile(sourcePath);
|
|
182
|
+
if (content.byteLength > MAX_AGGREGATE_BYTES)
|
|
183
|
+
throw new Error(`Aggregate exceeds 8 MiB: ${sourcePath}`);
|
|
184
|
+
let value;
|
|
185
|
+
try {
|
|
186
|
+
value = JSON.parse(content.toString('utf8'));
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
throw new Error(`Invalid aggregate JSON: ${sourcePath}`);
|
|
190
|
+
}
|
|
191
|
+
const aggregate = objectField(value, 'root');
|
|
192
|
+
const data = aggregate;
|
|
193
|
+
if (aggregate.schema_version !== '1.0')
|
|
194
|
+
fail('schema_version', 'must be "1.0"');
|
|
195
|
+
stringField(aggregate.version, 'version');
|
|
196
|
+
stringField(aggregate.start, 'start');
|
|
197
|
+
numberField(aggregate.duration_ms, 'duration_ms');
|
|
198
|
+
stringField(aggregate.target, 'target');
|
|
199
|
+
stringField(aggregate.output_dir, 'output_dir');
|
|
200
|
+
stringField(aggregate.model, 'model', true);
|
|
201
|
+
for (const key of [
|
|
202
|
+
'case_count',
|
|
203
|
+
'planned_run_count',
|
|
204
|
+
'completed_run_count',
|
|
205
|
+
'run_count',
|
|
206
|
+
'passed',
|
|
207
|
+
'failed',
|
|
208
|
+
'total_turns',
|
|
209
|
+
'usage_known_runs',
|
|
210
|
+
'usage_unknown_runs',
|
|
211
|
+
'known_cost_runs',
|
|
212
|
+
'unknown_cost_runs',
|
|
213
|
+
])
|
|
214
|
+
numberField(aggregate[key], key, true);
|
|
215
|
+
numberField(aggregate.pass_rate, 'pass_rate');
|
|
216
|
+
if (data.pass_rate > 1)
|
|
217
|
+
fail('pass_rate', 'must be within [0,1]');
|
|
218
|
+
boolField(aggregate.partial, 'partial');
|
|
219
|
+
boolField(aggregate.interrupted, 'interrupted');
|
|
220
|
+
const usageTotals = objectField(aggregate.usage_totals, 'usage_totals');
|
|
221
|
+
for (const key of [
|
|
222
|
+
'input_tokens',
|
|
223
|
+
'output_tokens',
|
|
224
|
+
'cache_read_input_tokens',
|
|
225
|
+
'cache_creation_input_tokens',
|
|
226
|
+
'web_search_requests',
|
|
227
|
+
])
|
|
228
|
+
numberField(usageTotals[key], `usage_totals.${key}`, true);
|
|
229
|
+
nullableNumber(aggregate.known_cost_total_usd, 'known_cost_total_usd');
|
|
230
|
+
if (!Array.isArray(aggregate.runs) || aggregate.runs.length > 100000)
|
|
231
|
+
fail('runs', 'expected a bounded array');
|
|
232
|
+
const runs = aggregate.runs.map(validateRun);
|
|
233
|
+
if (data.run_count !== runs.length)
|
|
234
|
+
fail('run_count', 'does not match runs length');
|
|
235
|
+
if (data.completed_run_count > data.planned_run_count ||
|
|
236
|
+
data.completed_run_count !== data.run_count)
|
|
237
|
+
fail('completed_run_count', 'inconsistent with planned/run counts');
|
|
238
|
+
if (data.case_count > data.planned_run_count)
|
|
239
|
+
fail('case_count', 'cannot exceed planned_run_count');
|
|
240
|
+
if (data.passed + data.failed !== data.run_count)
|
|
241
|
+
fail('passed', 'passed + failed must equal run_count');
|
|
242
|
+
if (data.passed !== runs.filter((run) => run.passed).length)
|
|
243
|
+
fail('passed', 'does not match run outcomes');
|
|
244
|
+
if (data.pass_rate !== (data.run_count === 0 ? 0 : data.passed / data.run_count))
|
|
245
|
+
fail('pass_rate', 'inconsistent with passed/run_count');
|
|
246
|
+
if (data.usage_known_runs + data.usage_unknown_runs !== data.run_count)
|
|
247
|
+
fail('usage_known_runs', 'usage totals are inconsistent');
|
|
248
|
+
if (data.known_cost_runs + data.unknown_cost_runs !== data.run_count)
|
|
249
|
+
fail('known_cost_runs', 'cost totals are inconsistent');
|
|
250
|
+
if (data.known_cost_total_usd === null && data.known_cost_runs !== 0)
|
|
251
|
+
fail('known_cost_total_usd', 'must be present when cost is known');
|
|
252
|
+
if (data.known_cost_total_usd !== null && data.known_cost_runs === 0)
|
|
253
|
+
fail('known_cost_total_usd', 'must be null when no cost is known');
|
|
254
|
+
if (data.usage_known_runs !== runs.filter((run) => run.usage !== null).length)
|
|
255
|
+
fail('usage_known_runs', 'does not match run usage');
|
|
256
|
+
if (data.usage_unknown_runs !== runs.filter((run) => run.usage === null).length)
|
|
257
|
+
fail('usage_unknown_runs', 'does not match run usage');
|
|
258
|
+
if (data.known_cost_runs !== runs.filter((run) => run.cost_known).length)
|
|
259
|
+
fail('known_cost_runs', 'does not match run costs');
|
|
260
|
+
if (data.unknown_cost_runs !== runs.filter((run) => !run.cost_known).length)
|
|
261
|
+
fail('unknown_cost_runs', 'does not match run costs');
|
|
262
|
+
if (data.known_cost_total_usd !== null) {
|
|
263
|
+
const cost = runs.reduce((total, run) => total + (run.cost_usd ?? 0), 0);
|
|
264
|
+
if (Math.abs(cost - data.known_cost_total_usd) > 1e-9)
|
|
265
|
+
fail('known_cost_total_usd', 'does not match run costs');
|
|
266
|
+
}
|
|
267
|
+
if (data.total_turns !== runs.reduce((total, run) => total + run.turns, 0))
|
|
268
|
+
fail('total_turns', 'does not match run turns');
|
|
269
|
+
const expectedUsage = {
|
|
270
|
+
input_tokens: runs.reduce((total, run) => total + (run.usage?.inputTokens ?? 0), 0),
|
|
271
|
+
output_tokens: runs.reduce((total, run) => total + (run.usage?.outputTokens ?? 0), 0),
|
|
272
|
+
cache_read_input_tokens: runs.reduce((total, run) => total + (run.usage?.cacheReadInputTokens ?? 0), 0),
|
|
273
|
+
cache_creation_input_tokens: runs.reduce((total, run) => total + (run.usage?.cacheCreationInputTokens ?? 0), 0),
|
|
274
|
+
web_search_requests: runs.reduce((total, run) => total + (run.usage?.webSearchRequests ?? 0), 0),
|
|
275
|
+
};
|
|
276
|
+
for (const key of Object.keys(expectedUsage))
|
|
277
|
+
if (usageTotals[key] !== expectedUsage[key])
|
|
278
|
+
fail(`usage_totals.${key}`, 'does not match run usage');
|
|
279
|
+
const keys = new Set();
|
|
280
|
+
const caseNames = new Set();
|
|
281
|
+
for (const run of runs) {
|
|
282
|
+
const key = `${run.case}\u0000${run.run}`;
|
|
283
|
+
if (keys.has(key))
|
|
284
|
+
fail('runs', 'duplicate (case,run) key');
|
|
285
|
+
keys.add(key);
|
|
286
|
+
caseNames.add(run.case);
|
|
287
|
+
}
|
|
288
|
+
if (!data.partial && caseNames.size !== data.case_count)
|
|
289
|
+
fail('case_count', 'does not match completed run cases');
|
|
290
|
+
if (data.interrupted && !data.partial)
|
|
291
|
+
fail('interrupted', 'interrupted aggregate must be partial');
|
|
292
|
+
if (!data.partial && data.run_count !== data.planned_run_count)
|
|
293
|
+
fail('partial', 'complete aggregate is missing planned runs');
|
|
294
|
+
if (data.partial !==
|
|
295
|
+
(data.interrupted || data.run_count < data.planned_run_count))
|
|
296
|
+
fail('partial', 'does not match interrupted/completed run state');
|
|
297
|
+
const aggregateEvidenceKnown = optionalGroup(aggregate, [
|
|
298
|
+
'safety_passed',
|
|
299
|
+
'safety_failed',
|
|
300
|
+
'permission_decisions',
|
|
301
|
+
'tool_errors',
|
|
302
|
+
'retries',
|
|
303
|
+
'terminations',
|
|
304
|
+
], 'root evidence');
|
|
305
|
+
const runEvidenceKnown = runs.every((run) => typeof run.safety_passed === 'boolean' &&
|
|
306
|
+
typeof run.tool_errors === 'number' &&
|
|
307
|
+
typeof run.retries === 'number' &&
|
|
308
|
+
run.permission_decisions !== undefined);
|
|
309
|
+
const runEvidenceAbsent = runs.every((run) => run.safety_passed === undefined &&
|
|
310
|
+
run.tool_errors === undefined &&
|
|
311
|
+
run.retries === undefined &&
|
|
312
|
+
run.permission_decisions === undefined);
|
|
313
|
+
if ((aggregateEvidenceKnown && !runEvidenceKnown) ||
|
|
314
|
+
(!aggregateEvidenceKnown && !runEvidenceAbsent))
|
|
315
|
+
fail('runs', 'run evidence must match aggregate evidence availability');
|
|
316
|
+
const safetyKnown = aggregateEvidenceKnown && runEvidenceKnown;
|
|
317
|
+
if (aggregateEvidenceKnown) {
|
|
318
|
+
numberField(aggregate.safety_passed, 'safety_passed', true);
|
|
319
|
+
numberField(aggregate.safety_failed, 'safety_failed', true);
|
|
320
|
+
const permissions = objectField(aggregate.permission_decisions, 'permission_decisions');
|
|
321
|
+
for (const key of ['allow', 'ask', 'deny'])
|
|
322
|
+
numberField(permissions[key], `permission_decisions.${key}`, true);
|
|
323
|
+
numberField(aggregate.tool_errors, 'tool_errors', true);
|
|
324
|
+
numberField(aggregate.retries, 'retries', true);
|
|
325
|
+
if (aggregate.safety_passed !==
|
|
326
|
+
runs.filter((run) => run.safety_passed).length ||
|
|
327
|
+
aggregate.safety_failed !==
|
|
328
|
+
runs.filter((run) => !run.safety_passed).length)
|
|
329
|
+
fail('safety_passed', 'does not match run safety evidence');
|
|
330
|
+
const expectedPermissions = {
|
|
331
|
+
allow: runs.reduce((total, run) => total + run.permission_decisions.allow, 0),
|
|
332
|
+
ask: runs.reduce((total, run) => total + run.permission_decisions.ask, 0),
|
|
333
|
+
deny: runs.reduce((total, run) => total + run.permission_decisions.deny, 0),
|
|
334
|
+
};
|
|
335
|
+
if (permissions.allow !== expectedPermissions.allow ||
|
|
336
|
+
permissions.ask !== expectedPermissions.ask ||
|
|
337
|
+
permissions.deny !== expectedPermissions.deny)
|
|
338
|
+
fail('permission_decisions', 'does not match run evidence');
|
|
339
|
+
if (aggregate.tool_errors !==
|
|
340
|
+
runs.reduce((total, run) => total + run.tool_errors, 0) ||
|
|
341
|
+
aggregate.retries !== runs.reduce((total, run) => total + run.retries, 0))
|
|
342
|
+
fail('tool_errors', 'does not match run evidence');
|
|
343
|
+
const terminations = objectField(aggregate.terminations, 'terminations');
|
|
344
|
+
for (const key of ['completed', 'timeout', 'interrupted'])
|
|
345
|
+
numberField(terminations[key], `terminations.${key}`, true);
|
|
346
|
+
const expectedTerminations = {
|
|
347
|
+
completed: runs.filter((run) => run.termination === null).length,
|
|
348
|
+
timeout: runs.filter((run) => run.termination === 'timeout').length,
|
|
349
|
+
interrupted: runs.filter((run) => run.termination === 'interrupted')
|
|
350
|
+
.length,
|
|
351
|
+
};
|
|
352
|
+
for (const key of Object.keys(expectedTerminations))
|
|
353
|
+
if (terminations[key] !== expectedTerminations[key])
|
|
354
|
+
fail(`terminations.${key}`, 'does not match runs');
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
aggregate: { ...aggregate, runs },
|
|
358
|
+
sourcePath,
|
|
359
|
+
safetyKnown,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
function metric(baseline, candidate) {
|
|
363
|
+
return { baseline, candidate, delta: candidate - baseline };
|
|
364
|
+
}
|
|
365
|
+
function nullableMetric(baseline, candidate) {
|
|
366
|
+
return baseline === null || candidate === null
|
|
367
|
+
? { baseline, candidate, delta: null }
|
|
368
|
+
: { baseline, candidate, delta: candidate - baseline };
|
|
369
|
+
}
|
|
370
|
+
function avg(values) {
|
|
371
|
+
return values.length === 0
|
|
372
|
+
? 0
|
|
373
|
+
: values.reduce((a, b) => a + b, 0) / values.length;
|
|
374
|
+
}
|
|
375
|
+
function compareRunIdentity(left, right) {
|
|
376
|
+
const leftKey = `${left.case}\u0000${left.run}`;
|
|
377
|
+
const rightKey = `${right.case}\u0000${right.run}`;
|
|
378
|
+
return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
|
|
379
|
+
}
|
|
380
|
+
export function compareProjectEvalAggregates(baseline, candidate, baselineName, candidateName) {
|
|
381
|
+
const left = baseline.aggregate;
|
|
382
|
+
const right = candidate.aggregate;
|
|
383
|
+
if (left.partial || left.interrupted || right.partial || right.interrupted)
|
|
384
|
+
throw new Error('Comparison requires complete, uninterrupted aggregates');
|
|
385
|
+
if (left.completed_run_count !== left.planned_run_count ||
|
|
386
|
+
right.completed_run_count !== right.planned_run_count)
|
|
387
|
+
throw new Error('Comparison requires every planned run to be completed');
|
|
388
|
+
const leftRuns = [...left.runs].sort(compareRunIdentity);
|
|
389
|
+
const rightRuns = [...right.runs].sort(compareRunIdentity);
|
|
390
|
+
if (leftRuns.length !== rightRuns.length ||
|
|
391
|
+
leftRuns.some((run, index) => run.case !== rightRuns[index]?.case ||
|
|
392
|
+
run.run !== rightRuns[index]?.run))
|
|
393
|
+
throw new Error('Aggregates have different comparable run sets');
|
|
394
|
+
if (leftRuns.length === 0)
|
|
395
|
+
throw new Error('Comparison requires at least one completed run');
|
|
396
|
+
const regressions = rightRuns.flatMap((run, index) => leftRuns[index]?.passed && !run.passed
|
|
397
|
+
? [
|
|
398
|
+
{
|
|
399
|
+
case: run.case,
|
|
400
|
+
run: run.run,
|
|
401
|
+
baseline_passed: true,
|
|
402
|
+
candidate_passed: false,
|
|
403
|
+
},
|
|
404
|
+
]
|
|
405
|
+
: []);
|
|
406
|
+
const safetyKnown = baseline.safetyKnown && candidate.safetyKnown;
|
|
407
|
+
const leftSafety = safetyKnown
|
|
408
|
+
? leftRuns.filter((run) => run.safety_passed).length / leftRuns.length
|
|
409
|
+
: null;
|
|
410
|
+
const rightSafety = safetyKnown
|
|
411
|
+
? rightRuns.filter((run) => run.safety_passed).length / rightRuns.length
|
|
412
|
+
: null;
|
|
413
|
+
const terms = (runs, kind) => runs.filter((run) => kind === 'completed'
|
|
414
|
+
? run.termination === null
|
|
415
|
+
: run.termination === kind).length;
|
|
416
|
+
const token = (field) => nullableMetric(left.usage_unknown_runs === 0 ? left.usage_totals[field] : null, right.usage_unknown_runs === 0 ? right.usage_totals[field] : null);
|
|
417
|
+
const permission = (name) => safetyKnown
|
|
418
|
+
? metric(leftRuns.reduce((n, r) => n + r.permission_decisions[name], 0), rightRuns.reduce((n, r) => n + r.permission_decisions[name], 0))
|
|
419
|
+
: nullableMetric(null, null);
|
|
420
|
+
const result = {
|
|
421
|
+
schema_version: '1.0',
|
|
422
|
+
baseline: {
|
|
423
|
+
name: baselineName,
|
|
424
|
+
source_path: baseline.sourcePath,
|
|
425
|
+
version: left.version,
|
|
426
|
+
model: left.model,
|
|
427
|
+
},
|
|
428
|
+
candidate: {
|
|
429
|
+
name: candidateName,
|
|
430
|
+
source_path: candidate.sourcePath,
|
|
431
|
+
version: right.version,
|
|
432
|
+
model: right.model,
|
|
433
|
+
},
|
|
434
|
+
comparable_run_count: leftRuns.length,
|
|
435
|
+
passed: right.pass_rate >= left.pass_rate &&
|
|
436
|
+
safetyKnown &&
|
|
437
|
+
(rightSafety ?? 0) >= (leftSafety ?? 0),
|
|
438
|
+
regressions,
|
|
439
|
+
metrics: {
|
|
440
|
+
pass_rate: metric(left.pass_rate, right.pass_rate),
|
|
441
|
+
safety_pass_rate: nullableMetric(leftSafety, rightSafety),
|
|
442
|
+
average_turns: metric(avg(leftRuns.map((r) => r.turns)), avg(rightRuns.map((r) => r.turns))),
|
|
443
|
+
input_tokens: token('input_tokens'),
|
|
444
|
+
output_tokens: token('output_tokens'),
|
|
445
|
+
cache_read_input_tokens: token('cache_read_input_tokens'),
|
|
446
|
+
cache_creation_input_tokens: token('cache_creation_input_tokens'),
|
|
447
|
+
known_cost_total_usd: nullableMetric(left.known_cost_total_usd, right.known_cost_total_usd),
|
|
448
|
+
average_duration_ms: metric(avg(leftRuns.map((r) => r.duration_ms)), avg(rightRuns.map((r) => r.duration_ms))),
|
|
449
|
+
permission_decisions: {
|
|
450
|
+
allow: permission('allow'),
|
|
451
|
+
ask: permission('ask'),
|
|
452
|
+
deny: permission('deny'),
|
|
453
|
+
},
|
|
454
|
+
tool_errors: safetyKnown
|
|
455
|
+
? metric(leftRuns.reduce((n, r) => n + r.tool_errors, 0), rightRuns.reduce((n, r) => n + r.tool_errors, 0))
|
|
456
|
+
: nullableMetric(null, null),
|
|
457
|
+
retries: safetyKnown
|
|
458
|
+
? metric(leftRuns.reduce((n, r) => n + r.retries, 0), rightRuns.reduce((n, r) => n + r.retries, 0))
|
|
459
|
+
: nullableMetric(null, null),
|
|
460
|
+
terminations: {
|
|
461
|
+
completed: metric(terms(leftRuns, 'completed'), terms(rightRuns, 'completed')),
|
|
462
|
+
timeout: metric(terms(leftRuns, 'timeout'), terms(rightRuns, 'timeout')),
|
|
463
|
+
interrupted: metric(terms(leftRuns, 'interrupted'), terms(rightRuns, 'interrupted')),
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
return result;
|
|
468
|
+
}
|
|
469
|
+
export async function executeProjectEvalCompareCommand(argv, io, callerCwd = process.cwd(), signal) {
|
|
470
|
+
const options = parseProjectEvalCompareOptions(argv);
|
|
471
|
+
if (options.help) {
|
|
472
|
+
io.stdout(`${PROJECT_EVAL_COMPARE_HELP}\n`);
|
|
473
|
+
return 0;
|
|
474
|
+
}
|
|
475
|
+
if (signal?.aborted)
|
|
476
|
+
return 130;
|
|
477
|
+
const baseline = await loadProjectEvalAggregate(options.baseline ?? '', callerCwd);
|
|
478
|
+
if (signal?.aborted)
|
|
479
|
+
return 130;
|
|
480
|
+
const candidate = await loadProjectEvalAggregate(options.candidate ?? '', callerCwd);
|
|
481
|
+
if (signal?.aborted)
|
|
482
|
+
return 130;
|
|
483
|
+
const result = compareProjectEvalAggregates(baseline, candidate, options.baselineName ?? '', options.candidateName ?? '');
|
|
484
|
+
if (signal?.aborted)
|
|
485
|
+
return 130;
|
|
486
|
+
const outputDir = resolve(callerCwd, options.outputDir ?? dirname(candidate.sourcePath));
|
|
487
|
+
if (signal?.aborted)
|
|
488
|
+
return 130;
|
|
489
|
+
await mkdir(outputDir, { recursive: true });
|
|
490
|
+
if (signal?.aborted)
|
|
491
|
+
return 130;
|
|
492
|
+
const outputPath = resolve(outputDir, 'comparison-result.json');
|
|
493
|
+
try {
|
|
494
|
+
const existing = await lstat(outputPath);
|
|
495
|
+
if (existing.isSymbolicLink())
|
|
496
|
+
throw new Error(`Comparison output path contains symlink: ${outputPath}`);
|
|
497
|
+
if (!existing.isFile())
|
|
498
|
+
throw new Error(`Comparison output path is not a regular file: ${outputPath}`);
|
|
499
|
+
}
|
|
500
|
+
catch (error) {
|
|
501
|
+
if (error.code !== 'ENOENT')
|
|
502
|
+
throw error;
|
|
503
|
+
}
|
|
504
|
+
if (signal?.aborted)
|
|
505
|
+
return 130;
|
|
506
|
+
await writeFileAtomically(outputPath, `${JSON.stringify(result, null, 2)}\n`);
|
|
507
|
+
if (options.json)
|
|
508
|
+
io.stdout(`${JSON.stringify(result)}\n`);
|
|
509
|
+
else
|
|
510
|
+
io.stdout(`${result.passed ? 'passed' : 'failed'}: ${result.metrics.pass_rate.delta >= 0 ? '+' : ''}${result.metrics.pass_rate.delta.toFixed(3)} pass rate, ${result.metrics.safety_pass_rate.delta === null ? 'unknown' : `${result.metrics.safety_pass_rate.delta >= 0 ? '+' : ''}${result.metrics.safety_pass_rate.delta.toFixed(3)} safety rate`}\n`);
|
|
511
|
+
return result.passed ? 0 : 1;
|
|
512
|
+
}
|
|
513
|
+
//# sourceMappingURL=project-eval-comparison.js.map
|
|
@@ -30,6 +30,14 @@ export interface ProjectEvalRunResult {
|
|
|
30
30
|
cost_known: boolean;
|
|
31
31
|
duration_ms: number;
|
|
32
32
|
termination: ProjectEvalTermination;
|
|
33
|
+
safety_passed: boolean;
|
|
34
|
+
permission_decisions: {
|
|
35
|
+
allow: number;
|
|
36
|
+
ask: number;
|
|
37
|
+
deny: number;
|
|
38
|
+
};
|
|
39
|
+
tool_errors: number;
|
|
40
|
+
retries: number;
|
|
33
41
|
graders: readonly EvalGraderResult[];
|
|
34
42
|
checks: readonly EvalGraderResult[];
|
|
35
43
|
artifacts: {
|
|
@@ -6,6 +6,38 @@ import { normalizeEvalTraceEvent, resolveEvalAllowedTools, } from './eval-contra
|
|
|
6
6
|
import { cleanupProjectEvalWorkspace, createProjectEvalWorkspace, diffProjectEvalWorkspace, } from './project-eval-workspace.js';
|
|
7
7
|
import { minimatch } from 'minimatch';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
|
+
const SAFETY_CHECKS = [
|
|
10
|
+
'trace-bounds',
|
|
11
|
+
'runtime-close',
|
|
12
|
+
'workspace-manifest',
|
|
13
|
+
'source-unchanged',
|
|
14
|
+
'allowed-paths',
|
|
15
|
+
'forbidden-paths',
|
|
16
|
+
'artifact-write',
|
|
17
|
+
'temp-cleanup',
|
|
18
|
+
];
|
|
19
|
+
function runEvidence(trace, checks) {
|
|
20
|
+
const permission_decisions = { allow: 0, ask: 0, deny: 0 };
|
|
21
|
+
let tool_errors = 0;
|
|
22
|
+
let retries = 0;
|
|
23
|
+
for (const event of trace) {
|
|
24
|
+
if (event.type === 'permission-decision') {
|
|
25
|
+
const behavior = event.behavior;
|
|
26
|
+
if (behavior === 'allow' || behavior === 'ask' || behavior === 'deny')
|
|
27
|
+
permission_decisions[behavior] += 1;
|
|
28
|
+
}
|
|
29
|
+
else if (event.type === 'tool-result' && event.isError === true)
|
|
30
|
+
tool_errors += 1;
|
|
31
|
+
else if (event.type === 'api-retry')
|
|
32
|
+
retries += 1;
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
safety_passed: SAFETY_CHECKS.every((name) => checks.find((check) => check.name === name)?.passed === true),
|
|
36
|
+
permission_decisions,
|
|
37
|
+
tool_errors,
|
|
38
|
+
retries,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
9
41
|
function check(name, passed, explanation, evidence) {
|
|
10
42
|
return {
|
|
11
43
|
name,
|
|
@@ -321,6 +353,7 @@ export async function runProjectEvalCase(options) {
|
|
|
321
353
|
? 'Temporary workspace removed'
|
|
322
354
|
: `Temporary workspace cleanup failed: ${tempCleanupError}`));
|
|
323
355
|
const passed = checks.every((item) => item.passed);
|
|
356
|
+
const evidence = runEvidence(trace, checks);
|
|
324
357
|
const primaryError = runtimeError ??
|
|
325
358
|
graderError ??
|
|
326
359
|
verifications.find((verification) => verification.error)?.error ??
|
|
@@ -341,6 +374,7 @@ export async function runProjectEvalCase(options) {
|
|
|
341
374
|
cost_known: costUsd !== null,
|
|
342
375
|
duration_ms: Date.now() - started,
|
|
343
376
|
termination,
|
|
377
|
+
...evidence,
|
|
344
378
|
graders,
|
|
345
379
|
checks,
|
|
346
380
|
artifacts: {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ModelUsage } from '../core/runtime.js';
|
|
2
2
|
import type { EvalRuntimeFactory } from './eval-contract.js';
|
|
3
3
|
import { type ProjectEvalRunResult } from './project-eval-runner.js';
|
|
4
|
-
export declare const PROJECT_EVAL_HELP = "Usage: praxis eval [options] <target>\n\nRun deterministic project outcome evaluations in isolated workspaces.\n\nOptions:\n --case <glob> Filter case names\n --tag <tag[,tag]> Filter tags; repeatable\n --runs <1..50> Override run count\n --model <model> Override model\n --allow-tools <rules> Grant gated tools; comma-separated and repeatable\n --run-verification Enable verifier subprocesses\n --output-dir <dir> Write artifacts to this directory\n --keep-temp Preserve temporary workspaces\n --json Print exactly one aggregate JSON value\n --verbose Print run progress to stderr\n -h, --help Display help";
|
|
4
|
+
export declare const PROJECT_EVAL_HELP = "Usage: praxis eval [options] <target>\n\nRun deterministic project outcome evaluations in isolated workspaces.\n\nOptions:\n --case <glob> Filter case names\n --tag <tag[,tag]> Filter tags; repeatable\n --runs <1..50> Override run count\n --model <model> Override model\n --allow-tools <rules> Grant gated tools; comma-separated and repeatable\n --run-verification Enable verifier subprocesses\n --output-dir <dir> Write artifacts to this directory\n --keep-temp Preserve temporary workspaces\n --json Print exactly one aggregate JSON value\n --verbose Print run progress to stderr\n -h, --help Display help\n\nUse praxis eval compare --help to compare two completed aggregate artifacts.";
|
|
5
5
|
export interface ProjectEvalDependencies {
|
|
6
6
|
runtimeFactory: EvalRuntimeFactory;
|
|
7
7
|
version?: string;
|
|
@@ -40,6 +40,14 @@ export interface ProjectEvalRunSummary {
|
|
|
40
40
|
cost_known: boolean;
|
|
41
41
|
duration_ms: number;
|
|
42
42
|
termination: ProjectEvalRunResult['termination'];
|
|
43
|
+
safety_passed: boolean;
|
|
44
|
+
permission_decisions: {
|
|
45
|
+
allow: number;
|
|
46
|
+
ask: number;
|
|
47
|
+
deny: number;
|
|
48
|
+
};
|
|
49
|
+
tool_errors: number;
|
|
50
|
+
retries: number;
|
|
43
51
|
error: string | null;
|
|
44
52
|
artifact_dir: string;
|
|
45
53
|
}
|
|
@@ -65,6 +73,20 @@ export interface ProjectEvalAggregate {
|
|
|
65
73
|
known_cost_total_usd: number | null;
|
|
66
74
|
known_cost_runs: number;
|
|
67
75
|
unknown_cost_runs: number;
|
|
76
|
+
safety_passed: number;
|
|
77
|
+
safety_failed: number;
|
|
78
|
+
permission_decisions: {
|
|
79
|
+
allow: number;
|
|
80
|
+
ask: number;
|
|
81
|
+
deny: number;
|
|
82
|
+
};
|
|
83
|
+
tool_errors: number;
|
|
84
|
+
retries: number;
|
|
85
|
+
terminations: {
|
|
86
|
+
completed: number;
|
|
87
|
+
timeout: number;
|
|
88
|
+
interrupted: number;
|
|
89
|
+
};
|
|
68
90
|
partial: boolean;
|
|
69
91
|
interrupted: boolean;
|
|
70
92
|
runs: readonly ProjectEvalRunSummary[];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join, relative, resolve } from 'node:path';
|
|
2
2
|
import { writeFileAtomically } from '../platform/atomic-write.js';
|
|
3
3
|
import { runProjectEvalCase, } from './project-eval-runner.js';
|
|
4
|
+
import { executeProjectEvalCompareCommand } from './project-eval-comparison.js';
|
|
4
5
|
import { discoverProjectEvalCases } from './project-eval-schema.js';
|
|
5
6
|
export const PROJECT_EVAL_HELP = `Usage: praxis eval [options] <target>
|
|
6
7
|
|
|
@@ -17,7 +18,9 @@ Options:
|
|
|
17
18
|
--keep-temp Preserve temporary workspaces
|
|
18
19
|
--json Print exactly one aggregate JSON value
|
|
19
20
|
--verbose Print run progress to stderr
|
|
20
|
-
-h, --help Display help
|
|
21
|
+
-h, --help Display help
|
|
22
|
+
|
|
23
|
+
Use praxis eval compare --help to compare two completed aggregate artifacts.`;
|
|
21
24
|
function takeValue(argv, index, option) {
|
|
22
25
|
const value = argv[index + 1];
|
|
23
26
|
if (!value || value.startsWith('-'))
|
|
@@ -125,11 +128,17 @@ function runSummary(result, outputDirectory) {
|
|
|
125
128
|
cost_known: result.cost_known,
|
|
126
129
|
duration_ms: result.duration_ms,
|
|
127
130
|
termination: result.termination,
|
|
131
|
+
safety_passed: result.safety_passed,
|
|
132
|
+
permission_decisions: result.permission_decisions,
|
|
133
|
+
tool_errors: result.tool_errors,
|
|
134
|
+
retries: result.retries,
|
|
128
135
|
error: result.error,
|
|
129
136
|
artifact_dir: relative(outputDirectory, join(outputDirectory, result.case, `run-${result.run}`)).replaceAll('\\', '/'),
|
|
130
137
|
};
|
|
131
138
|
}
|
|
132
139
|
export async function executeProjectEvalCommand(argv, io, dependencies, callerCwd = process.cwd(), signal) {
|
|
140
|
+
if (argv[0] === 'compare')
|
|
141
|
+
return executeProjectEvalCompareCommand(argv.slice(1), io, callerCwd, signal);
|
|
133
142
|
const options = parseProjectEvalOptions(argv);
|
|
134
143
|
if (options.help) {
|
|
135
144
|
io.stdout(PROJECT_EVAL_HELP);
|
|
@@ -200,6 +209,21 @@ export async function executeProjectEvalCommand(argv, io, dependencies, callerCw
|
|
|
200
209
|
: knownCostResults.reduce((total, result) => total + (result.cost_usd ?? 0), 0),
|
|
201
210
|
known_cost_runs: knownCostResults.length,
|
|
202
211
|
unknown_cost_runs: results.length - knownCostResults.length,
|
|
212
|
+
safety_passed: results.filter((result) => result.safety_passed).length,
|
|
213
|
+
safety_failed: results.filter((result) => !result.safety_passed).length,
|
|
214
|
+
permission_decisions: {
|
|
215
|
+
allow: results.reduce((total, result) => total + result.permission_decisions.allow, 0),
|
|
216
|
+
ask: results.reduce((total, result) => total + result.permission_decisions.ask, 0),
|
|
217
|
+
deny: results.reduce((total, result) => total + result.permission_decisions.deny, 0),
|
|
218
|
+
},
|
|
219
|
+
tool_errors: results.reduce((total, result) => total + result.tool_errors, 0),
|
|
220
|
+
retries: results.reduce((total, result) => total + result.retries, 0),
|
|
221
|
+
terminations: {
|
|
222
|
+
completed: results.filter((result) => result.termination === null).length,
|
|
223
|
+
timeout: results.filter((result) => result.termination === 'timeout')
|
|
224
|
+
.length,
|
|
225
|
+
interrupted: results.filter((result) => result.termination === 'interrupted').length,
|
|
226
|
+
},
|
|
203
227
|
partial: interrupted || results.length < plannedRunCount,
|
|
204
228
|
interrupted,
|
|
205
229
|
runs: results.map((result) => runSummary(result, outputDirectory)),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "praxis-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.48.0",
|
|
4
4
|
"description": "Local-first, single-user general agent for the command line.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "wuqisen",
|
|
@@ -61,7 +61,8 @@
|
|
|
61
61
|
"verify:release-automation": "node scripts/verify-release-automation.mjs",
|
|
62
62
|
"verify:ci-coverage": "node scripts/verify-ci-coverage.mjs",
|
|
63
63
|
"verify:fixture-contracts": "node scripts/verify-fixture-contracts.mjs",
|
|
64
|
-
"test:fixtures": "node scripts/run-fixture-contracts.mjs"
|
|
64
|
+
"test:fixtures": "node scripts/run-fixture-contracts.mjs",
|
|
65
|
+
"test:eval:baseline": "vitest run src/evals/coding-baseline.test.ts src/evals/project-eval-comparison.test.ts"
|
|
65
66
|
},
|
|
66
67
|
"engines": {
|
|
67
68
|
"node": ">=24"
|