praxis-agent 0.47.0 → 0.48.1
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 +7 -4
- package/dist/application/session-service.js +6 -2
- package/dist/cli-runtime.js +8 -0
- package/dist/core/runtime.d.ts +7 -0
- package/dist/core/runtime.js +21 -1
- package/dist/core/transcript-event.js +9 -2
- 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/dist/providers/anthropic-compatible.js +11 -3
- package/dist/providers/codex-subscription.js +7 -2
- package/dist/providers/openai-compatible.js +11 -3
- 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,
|
|
@@ -213,8 +215,9 @@ troubleshooting. Run `praxis --help` for the authoritative command surface.
|
|
|
213
215
|
disconnect recovery that never replays an already-dispatched call.
|
|
214
216
|
- **Provider-neutral models** — native Provider Registry/Vault routing, API
|
|
215
217
|
adapters, an experimental Codex OAuth adapter, explicit capability checks,
|
|
216
|
-
per-attempt bounded deadlines,
|
|
217
|
-
|
|
218
|
+
per-attempt bounded deadlines, typed recovery for malformed streamed tool
|
|
219
|
+
arguments without tool execution or lost resumability, and
|
|
220
|
+
token-only/no-API-dollar accounting for subscription runs.
|
|
218
221
|
- **Transactional self-update** — `praxis update` verifies the package before
|
|
219
222
|
installing it, rejects concurrent updates, and can roll back after an
|
|
220
223
|
interruption or crash.
|
|
@@ -299,7 +302,7 @@ normal/low-capability full-frame p95 budgets of `<16.7/<33 ms`.
|
|
|
299
302
|
`npm run test:coverage` measures all production code under `src/**` with V8 and
|
|
300
303
|
enforces global floors of 79% statements, 70% branches, 85% functions, and 81% lines,
|
|
301
304
|
and rejects any production runtime module with zero covered statements (while allowing
|
|
302
|
-
type-only modules). `npm run test:fixtures` executes the
|
|
305
|
+
type-only modules). `npm run test:fixtures` executes the 69-behavior native contract; 61 behaviors
|
|
303
306
|
are qualified and 8 are explicitly excluded. `npm run verify:fixture-contracts`
|
|
304
307
|
performs the structural check and is part of `npm run check`.
|
|
305
308
|
`npm run test:core-completion` is retained as a compatibility alias for
|
|
@@ -15,7 +15,7 @@ import { projectNativeSessionEntries } from './native-session-projection.js';
|
|
|
15
15
|
import { classifyClaudeInterruption, } from '../native/interruption.js';
|
|
16
16
|
import { findUnresolvedClaudeToolCalls, getClaudeContentBlocks, } from '../native/tool-links.js';
|
|
17
17
|
import { createClaudeAgentSettingEntry, createClaudeHookAttachmentEntries, createClaudeLastPromptEntry, createClaudeRuleAttachmentEntry, translateProviderEvents, } from '../native/translation.js';
|
|
18
|
-
import { AgentRunCancelledError, AgentRuntime, ModelProviderError, } from '../core/runtime.js';
|
|
18
|
+
import { AgentRunCancelledError, AgentRuntime, MALFORMED_TOOL_INPUT_MESSAGE, ModelProviderError, } from '../core/runtime.js';
|
|
19
19
|
import { resolveToolSchedulingPolicy } from '../core/tool-scheduling-policy.js';
|
|
20
20
|
import { BackgroundTaskRuntime, } from './background-task-runtime.js';
|
|
21
21
|
import { backgroundAgentNotificationMarkers, } from './background-agent-manager.js';
|
|
@@ -3347,7 +3347,11 @@ export class ClaudeSessionService {
|
|
|
3347
3347
|
: {}),
|
|
3348
3348
|
};
|
|
3349
3349
|
const unresolvedToolCall = unresolvedToolCalls[0];
|
|
3350
|
-
if (unresolvedToolCall &&
|
|
3350
|
+
if (unresolvedToolCall &&
|
|
3351
|
+
!unresolvedToolCalls.every((call) => call.inputError?.kind === 'malformed_json' &&
|
|
3352
|
+
call.inputError?.message ===
|
|
3353
|
+
MALFORMED_TOOL_INPUT_MESSAGE) &&
|
|
3354
|
+
!approveRecovery) {
|
|
3351
3355
|
throw new Error(`Claude session tool call ${unresolvedToolCall.id} requires explicit recovery approval`);
|
|
3352
3356
|
}
|
|
3353
3357
|
const recoveryResults = await runtime.recoverToolCalls(unresolvedToolCalls, recoveryRequest);
|
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')) {
|
package/dist/core/runtime.d.ts
CHANGED
|
@@ -43,7 +43,14 @@ export interface ModelToolCall {
|
|
|
43
43
|
id: string;
|
|
44
44
|
name: string;
|
|
45
45
|
input: Record<string, unknown>;
|
|
46
|
+
inputError?: ModelToolInputError;
|
|
46
47
|
}
|
|
48
|
+
export declare const MALFORMED_TOOL_INPUT_MESSAGE = "Malformed tool input";
|
|
49
|
+
export interface ModelToolInputError {
|
|
50
|
+
kind: 'malformed_json';
|
|
51
|
+
message: typeof MALFORMED_TOOL_INPUT_MESSAGE;
|
|
52
|
+
}
|
|
53
|
+
export declare function malformedModelToolCall(id: string, name: string): ModelToolCall;
|
|
47
54
|
export type ModelThinkingMode = 'enabled' | 'adaptive' | 'disabled';
|
|
48
55
|
export type ModelThinkingBlock = {
|
|
49
56
|
type: 'thinking';
|
package/dist/core/runtime.js
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { ToolExecutionScheduler } from './tool-execution-scheduler.js';
|
|
2
2
|
import { resolveToolSchedulingPolicy } from './tool-scheduling-policy.js';
|
|
3
|
+
export const MALFORMED_TOOL_INPUT_MESSAGE = 'Malformed tool input';
|
|
4
|
+
export function malformedModelToolCall(id, name) {
|
|
5
|
+
return {
|
|
6
|
+
id,
|
|
7
|
+
name,
|
|
8
|
+
input: {},
|
|
9
|
+
inputError: {
|
|
10
|
+
kind: 'malformed_json',
|
|
11
|
+
message: MALFORMED_TOOL_INPUT_MESSAGE,
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
}
|
|
3
15
|
const permissionDecisionSources = new WeakMap();
|
|
4
16
|
const autoModePermissionOutcomes = new WeakMap();
|
|
5
17
|
export function annotatePermissionDecision(decision, source) {
|
|
@@ -516,7 +528,12 @@ export class AgentRuntime {
|
|
|
516
528
|
}
|
|
517
529
|
toolCalls.push(event.call);
|
|
518
530
|
this.emit(event);
|
|
519
|
-
const policy =
|
|
531
|
+
const policy = event.call.inputError
|
|
532
|
+
? {
|
|
533
|
+
concurrency: 'exclusive',
|
|
534
|
+
startAfterAssistant: true,
|
|
535
|
+
}
|
|
536
|
+
: resolveToolSchedulingPolicy(this.options.tools, event.call);
|
|
520
537
|
toolScheduler.schedule(event.call, request.observer?.toolExecutionStarted
|
|
521
538
|
? { ...policy, startAfterAssistant: true }
|
|
522
539
|
: policy);
|
|
@@ -863,6 +880,9 @@ export class AgentRuntime {
|
|
|
863
880
|
try {
|
|
864
881
|
if (emitPresentation)
|
|
865
882
|
emitProgress(0);
|
|
883
|
+
if (call.inputError) {
|
|
884
|
+
return { content: call.inputError.message, isError: true };
|
|
885
|
+
}
|
|
866
886
|
const executed = await this.executeTool(call, { ...request, signal }, messages);
|
|
867
887
|
const unsupportedImages = executed.images?.length && this.provider.capabilities.images !== true;
|
|
868
888
|
const unsupportedDocuments = executed.documents?.length &&
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { MALFORMED_TOOL_INPUT_MESSAGE } from './runtime.js';
|
|
1
2
|
const terminalReasons = new Set([
|
|
2
3
|
'end_turn',
|
|
3
4
|
'tool_use',
|
|
@@ -51,11 +52,17 @@ export function isModelContentBlock(value) {
|
|
|
51
52
|
return isModelMedia(value);
|
|
52
53
|
}
|
|
53
54
|
function isModelToolCall(value) {
|
|
55
|
+
const inputError = isRecord(value) ? value.inputError : undefined;
|
|
54
56
|
return (isRecord(value) &&
|
|
55
|
-
hasOnlyKeys(value, ['id', 'name', 'input']) &&
|
|
57
|
+
hasOnlyKeys(value, ['id', 'name', 'input', 'inputError']) &&
|
|
56
58
|
isNonEmptyString(value.id) &&
|
|
57
59
|
isNonEmptyString(value.name) &&
|
|
58
|
-
isRecord(value.input)
|
|
60
|
+
isRecord(value.input) &&
|
|
61
|
+
(inputError === undefined ||
|
|
62
|
+
(isRecord(inputError) &&
|
|
63
|
+
hasOnlyKeys(inputError, ['kind', 'message']) &&
|
|
64
|
+
inputError.kind === 'malformed_json' &&
|
|
65
|
+
inputError.message === MALFORMED_TOOL_INPUT_MESSAGE)));
|
|
59
66
|
}
|
|
60
67
|
function isModelThinkingBlock(value) {
|
|
61
68
|
return isRecord(value) && value.type === 'thinking'
|
|
@@ -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)),
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ModelProviderError, } from '../core/runtime.js';
|
|
1
|
+
import { ModelProviderError, malformedModelToolCall, } from '../core/runtime.js';
|
|
2
2
|
import { transportFailureKind } from './provider-errors.js';
|
|
3
3
|
import { createAnthropicPromptCachePolicyResolver, } from './anthropic-prompt-cache.js';
|
|
4
4
|
function isRecord(value) {
|
|
@@ -119,8 +119,16 @@ function completedToolCall(state, index) {
|
|
|
119
119
|
try {
|
|
120
120
|
input = JSON.parse(pending.partialJson);
|
|
121
121
|
}
|
|
122
|
-
catch
|
|
123
|
-
|
|
122
|
+
catch {
|
|
123
|
+
if (!pending.id || !pending.name) {
|
|
124
|
+
throw new ModelProviderError('Provider returned an invalid tool call', {
|
|
125
|
+
retryable: false,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
type: 'tool-call',
|
|
130
|
+
call: malformedModelToolCall(pending.id, pending.name),
|
|
131
|
+
};
|
|
124
132
|
}
|
|
125
133
|
}
|
|
126
134
|
if (!pending.id || !pending.name || !isRecord(input)) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ModelProviderError, } from '../core/runtime.js';
|
|
1
|
+
import { ModelProviderError, malformedModelToolCall, } from '../core/runtime.js';
|
|
2
2
|
import { CodexOAuthError, } from './codex-oauth.js';
|
|
3
3
|
export const CODEX_RESPONSES_ENDPOINT = 'https://chatgpt.com/backend-api/codex/responses';
|
|
4
4
|
const DEFAULT_MAX_STREAM = 1024 * 1024;
|
|
@@ -572,7 +572,12 @@ function parseEvent(value, state, limits) {
|
|
|
572
572
|
parsed = JSON.parse(call.arguments || '{}');
|
|
573
573
|
}
|
|
574
574
|
catch {
|
|
575
|
-
|
|
575
|
+
if (!id || !name)
|
|
576
|
+
throw invalid('Codex provider returned an invalid tool call');
|
|
577
|
+
call.emitted = true;
|
|
578
|
+
state.toolCallEmitted = true;
|
|
579
|
+
events.push({ type: 'tool-call', call: malformedModelToolCall(id, name) });
|
|
580
|
+
return events;
|
|
576
581
|
}
|
|
577
582
|
if (!isRecord(parsed))
|
|
578
583
|
throw invalid('Codex provider returned non-object function arguments');
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ModelProviderError, } from '../core/runtime.js';
|
|
1
|
+
import { ModelProviderError, malformedModelToolCall, } from '../core/runtime.js';
|
|
2
2
|
import { transportFailureKind } from './provider-errors.js';
|
|
3
3
|
function isRecord(value) {
|
|
4
4
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
@@ -64,8 +64,16 @@ function completedToolCallEvents(pending) {
|
|
|
64
64
|
try {
|
|
65
65
|
input = JSON.parse(call.arguments || '{}');
|
|
66
66
|
}
|
|
67
|
-
catch
|
|
68
|
-
|
|
67
|
+
catch {
|
|
68
|
+
if (!call.id || !call.name) {
|
|
69
|
+
throw new ModelProviderError('Provider returned an invalid tool call', {
|
|
70
|
+
retryable: false,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
type: 'tool-call',
|
|
75
|
+
call: malformedModelToolCall(call.id, call.name),
|
|
76
|
+
};
|
|
69
77
|
}
|
|
70
78
|
if (!isRecord(input) || !call.id || !call.name) {
|
|
71
79
|
throw new ModelProviderError('Provider returned an invalid tool call', {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "praxis-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.48.1",
|
|
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"
|