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 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, and token-only/no-API-dollar accounting for
217
- subscription runs.
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 67-behavior native contract; 59 behaviors
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 && !approveRecovery) {
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);
@@ -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')) {
@@ -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';
@@ -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 = resolveToolSchedulingPolicy(this.options.tools, event.call);
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 (error) {
123
- throw new ModelProviderError(`Provider returned malformed tool arguments for ${pending.name}`, { retryable: false, cause: error });
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
- throw invalid('Codex provider returned malformed function arguments');
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 (error) {
68
- throw new ModelProviderError(`Provider returned malformed tool arguments for ${call.name}`, { retryable: false, cause: error });
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.47.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"