praxis-agent 0.64.1 → 0.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -134,10 +134,12 @@ troubleshooting. Run `praxis --help` for the authoritative command surface.
134
134
 
135
135
  - **Outcome-driven evaluation** — `praxis eval <target>` runs contained cases
136
136
  in isolated workspaces, requires explicit verifier authorization, and writes
137
- versioned artifacts locally; usage and cost remain explicitly available or
138
- unknown. Separate runs can be compared with `praxis eval compare`; unknown
139
- token/cost evidence produces null deltas, while the gate requires no pass-rate
140
- or safety-rate regression and rejects incomplete safety evidence.
137
+ versioned artifacts with deterministic run identities locally; usage and cost
138
+ remain explicitly available or unknown. Separate runs can be compared with
139
+ `praxis eval compare` only when provider, model, configuration, tools, prompt,
140
+ corpus, and runtime-environment identities match. Unknown token/cost evidence
141
+ produces null deltas, while the gate requires no pass-rate or safety-rate
142
+ regression and rejects incomplete safety evidence.
141
143
  - **Local agent runtime** — C+ Quiet Operator responsive TUI with a linear
142
144
  `❯` user / `⏺` assistant conversation, `✻` thinking activity, and `!` shell
143
145
  composer grammar, compact stable tool rows, responsive density,
@@ -51,7 +51,7 @@ import { FallbackModelProvider } from './providers/fallback-provider.js';
51
51
  import { ProviderCredentialVault } from './persistence/provider-credential-vault.js';
52
52
  import { parseContextEnvironment, parseProviderEnvironment, } from './providers/environment.js';
53
53
  import { ProviderAuthenticationError, resolveProviderCredential, } from './providers/provider-auth.js';
54
- import { resolveProviderContextWindowTokens, resolveProviderRegistry, } from './providers/provider-registry.js';
54
+ import { resolveProviderContextWindowTokens, resolveProviderRegistry, resolveProviderRuntimeTarget, } from './providers/provider-registry.js';
55
55
  import { ProviderSettingsError, resolveProviderTarget, } from './providers/provider-settings.js';
56
56
  import { ModelPricingRegistry, usageCostUsd } from './core/usage.js';
57
57
  import { LocalToolRegistry } from './tools/local-tools.js';
@@ -2358,6 +2358,27 @@ const defaultPluginEvalRuntimeFactory = {
2358
2358
  };
2359
2359
  },
2360
2360
  };
2361
+ const defaultProjectEvalRuntimeFactory = {
2362
+ create: (options) => defaultPluginEvalRuntimeFactory.create(options),
2363
+ identify: async (options) => {
2364
+ const environment = process.env;
2365
+ const target = await resolveProviderRuntimeTarget({
2366
+ configRoot: options.configRoot,
2367
+ cwd: options.cwd,
2368
+ environment,
2369
+ ...(options.model === undefined ? {} : { model: options.model }),
2370
+ includeSettings: true,
2371
+ includeProjectSettings: false,
2372
+ });
2373
+ return {
2374
+ providerId: target.providerId,
2375
+ profileId: target.profileId,
2376
+ protocol: target.protocol,
2377
+ endpoint: target.baseUrl,
2378
+ modelId: target.modelId,
2379
+ };
2380
+ },
2381
+ };
2361
2382
  const defaultPluginEvalJudge = {
2362
2383
  vote: async ({ criteria, focus, baseline, model, signal }) => {
2363
2384
  const environment = process.env;
@@ -2600,7 +2621,7 @@ export function createDefaultDependencies(entrypoint = fileURLToPath(import.meta
2600
2621
  judge: defaultPluginEvalJudge,
2601
2622
  },
2602
2623
  projectEval: {
2603
- runtimeFactory: defaultPluginEvalRuntimeFactory,
2624
+ runtimeFactory: defaultProjectEvalRuntimeFactory,
2604
2625
  version: VERSION,
2605
2626
  configRoot: resolveDataPlaneRoot(),
2606
2627
  },
@@ -27,6 +27,17 @@ export interface EvalRuntimeFactoryOptions {
27
27
  export interface EvalRuntimeFactory {
28
28
  create(options: EvalRuntimeFactoryOptions): Promise<EvalRuntime>;
29
29
  }
30
+ export type EvalRuntimeFactoryIdentityOptions = Omit<EvalRuntimeFactoryOptions, 'eventSink'>;
31
+ export interface EvalRuntimeIdentityDescriptor {
32
+ providerId: string;
33
+ profileId: string;
34
+ protocol: string;
35
+ endpoint: string;
36
+ modelId: string;
37
+ }
38
+ export interface IdentifiedEvalRuntimeFactory extends EvalRuntimeFactory {
39
+ identify(options: EvalRuntimeFactoryIdentityOptions): Promise<EvalRuntimeIdentityDescriptor>;
40
+ }
30
41
  export interface EvalTraceEvent {
31
42
  type: string;
32
43
  tool?: string;
@@ -20,18 +20,20 @@ export interface ProjectEvalComparisonMetric<T = number | null> {
20
20
  delta: T;
21
21
  }
22
22
  export interface ProjectEvalComparisonResult {
23
- schema_version: '1.0';
23
+ schema_version: '1.1';
24
24
  baseline: {
25
25
  name: string;
26
26
  source_path: string;
27
27
  version: string;
28
28
  model: string | null;
29
+ identity_sha256: `sha256:${string}`;
29
30
  };
30
31
  candidate: {
31
32
  name: string;
32
33
  source_path: string;
33
34
  version: string;
34
35
  model: string | null;
36
+ identity_sha256: `sha256:${string}`;
35
37
  };
36
38
  comparable_run_count: number;
37
39
  passed: boolean;
@@ -1,6 +1,7 @@
1
1
  import { mkdir, lstat, readFile } from 'node:fs/promises';
2
2
  import { dirname, resolve } from 'node:path';
3
3
  import { writeFileAtomically } from '../platform/atomic-write.js';
4
+ import { assertProjectEvalIdentitiesComparable, validateProjectEvalAggregateIdentity, validateProjectEvalIdentity, } from './project-eval-identity.js';
4
5
  const MAX_AGGREGATE_BYTES = 8 * 1024 * 1024;
5
6
  const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
6
7
  export const PROJECT_EVAL_COMPARE_HELP = `Usage: praxis eval compare [options]
@@ -139,7 +140,10 @@ function validateRun(value, index) {
139
140
  const runNumber = numberField(run.run, `${path}.run`, true);
140
141
  if (runNumber < 1)
141
142
  fail(`${path}.run`, 'must be positive');
142
- stringField(run.model, `${path}.model`, true);
143
+ const model = stringField(run.model, `${path}.model`);
144
+ const identity = validateProjectEvalIdentity(run.identity);
145
+ if (model !== identity.model_id)
146
+ fail(`${path}.model`, 'does not match identity.model_id');
143
147
  boolField(run.passed, `${path}.passed`);
144
148
  if (run.score !== 0 && run.score !== 1)
145
149
  fail(`${path}.score`, 'must be 0 or 1');
@@ -167,7 +171,7 @@ function validateRun(value, index) {
167
171
  numberField(run.tool_errors, `${path}.tool_errors`, true);
168
172
  numberField(run.retries, `${path}.retries`, true);
169
173
  }
170
- return run;
174
+ return { ...run, model, identity };
171
175
  }
172
176
  export async function loadProjectEvalAggregate(inputPath, callerCwd = process.cwd()) {
173
177
  const sourcePath = resolve(callerCwd, inputPath);
@@ -190,8 +194,8 @@ export async function loadProjectEvalAggregate(inputPath, callerCwd = process.cw
190
194
  }
191
195
  const aggregate = objectField(value, 'root');
192
196
  const data = aggregate;
193
- if (aggregate.schema_version !== '1.0')
194
- fail('schema_version', 'must be "1.0"');
197
+ if (aggregate.schema_version !== '1.1')
198
+ fail('schema_version', 'must be "1.1"; legacy "1.0" aggregates are unsupported');
195
199
  stringField(aggregate.version, 'version');
196
200
  stringField(aggregate.start, 'start');
197
201
  numberField(aggregate.duration_ms, 'duration_ms');
@@ -230,6 +234,14 @@ export async function loadProjectEvalAggregate(inputPath, callerCwd = process.cw
230
234
  if (!Array.isArray(aggregate.runs) || aggregate.runs.length > 100000)
231
235
  fail('runs', 'expected a bounded array');
232
236
  const runs = aggregate.runs.map(validateRun);
237
+ for (const [index, run] of runs.entries())
238
+ if (run.identity.runtime.praxis_version !== aggregate.version)
239
+ fail(`runs[${index}].identity.runtime.praxis_version`, 'must match aggregate version');
240
+ const expectedAggregateModel = runs.length > 0 && runs.every((run) => run.model === runs[0]?.model)
241
+ ? (runs[0]?.model ?? null)
242
+ : null;
243
+ if (aggregate.model !== expectedAggregateModel)
244
+ fail('model', 'does not match completed run identity models');
233
245
  if (data.run_count !== runs.length)
234
246
  fail('run_count', 'does not match runs length');
235
247
  if (data.completed_run_count > data.planned_run_count ||
@@ -285,6 +297,11 @@ export async function loadProjectEvalAggregate(inputPath, callerCwd = process.cw
285
297
  keys.add(key);
286
298
  caseNames.add(run.case);
287
299
  }
300
+ validateProjectEvalAggregateIdentity(aggregate.identity_sha256, runs.map((run) => ({
301
+ case: run.case,
302
+ run: run.run,
303
+ identity_sha256: run.identity.identity_sha256,
304
+ })));
288
305
  if (!data.partial && caseNames.size !== data.case_count)
289
306
  fail('case_count', 'does not match completed run cases');
290
307
  if (data.interrupted && !data.partial)
@@ -393,6 +410,13 @@ export function compareProjectEvalAggregates(baseline, candidate, baselineName,
393
410
  throw new Error('Aggregates have different comparable run sets');
394
411
  if (leftRuns.length === 0)
395
412
  throw new Error('Comparison requires at least one completed run');
413
+ for (let index = 0; index < leftRuns.length; index += 1) {
414
+ const leftRun = leftRuns[index];
415
+ const rightRun = rightRuns[index];
416
+ if (!leftRun || !rightRun)
417
+ continue;
418
+ assertProjectEvalIdentitiesComparable(leftRun.identity, rightRun.identity, `Identity mismatch for (${leftRun.case}, ${leftRun.run})`);
419
+ }
396
420
  const regressions = rightRuns.flatMap((run, index) => leftRuns[index]?.passed && !run.passed
397
421
  ? [
398
422
  {
@@ -418,18 +442,20 @@ export function compareProjectEvalAggregates(baseline, candidate, baselineName,
418
442
  ? metric(leftRuns.reduce((n, r) => n + r.permission_decisions[name], 0), rightRuns.reduce((n, r) => n + r.permission_decisions[name], 0))
419
443
  : nullableMetric(null, null);
420
444
  const result = {
421
- schema_version: '1.0',
445
+ schema_version: '1.1',
422
446
  baseline: {
423
447
  name: baselineName,
424
448
  source_path: baseline.sourcePath,
425
449
  version: left.version,
426
450
  model: left.model,
451
+ identity_sha256: left.identity_sha256,
427
452
  },
428
453
  candidate: {
429
454
  name: candidateName,
430
455
  source_path: candidate.sourcePath,
431
456
  version: right.version,
432
457
  model: right.model,
458
+ identity_sha256: right.identity_sha256,
433
459
  },
434
460
  comparable_run_count: leftRuns.length,
435
461
  passed: regressions.length === 0 &&
@@ -0,0 +1,51 @@
1
+ import type { EvalRuntimeIdentityDescriptor } from './eval-contract.js';
2
+ import type { ProjectEvalCase } from './project-eval-schema.js';
3
+ import type { FileManifest } from './project-eval-workspace.js';
4
+ export declare const PROJECT_EVAL_IDENTITY_SCHEMA_VERSION: "1.0";
5
+ export type ProjectEvalIdentitySchemaVersion = typeof PROJECT_EVAL_IDENTITY_SCHEMA_VERSION;
6
+ export type IdentityDigest = `sha256:${string}`;
7
+ export interface ProjectEvalRuntimeIdentity {
8
+ engine: 'praxis';
9
+ praxis_version: string;
10
+ node_version: string;
11
+ platform: string;
12
+ architecture: string;
13
+ runtime_sha256: IdentityDigest;
14
+ }
15
+ export interface ProjectEvalIdentity {
16
+ schema_version: ProjectEvalIdentitySchemaVersion;
17
+ provider_id: string;
18
+ profile_id: string;
19
+ protocol: string;
20
+ endpoint_sha256: IdentityDigest;
21
+ model_id: string;
22
+ configuration_sha256: IdentityDigest;
23
+ tools_sha256: IdentityDigest;
24
+ prompt_sha256: IdentityDigest;
25
+ corpus_sha256: IdentityDigest;
26
+ runtime: ProjectEvalRuntimeIdentity;
27
+ identity_sha256: IdentityDigest;
28
+ }
29
+ export interface CreateProjectEvalIdentityInput {
30
+ provider: EvalRuntimeIdentityDescriptor;
31
+ case: ProjectEvalCase;
32
+ sourceBefore: FileManifest;
33
+ effectiveTools: readonly string[];
34
+ runVerification: boolean;
35
+ praxisVersion: string;
36
+ nodeVersion?: string;
37
+ platform?: string;
38
+ architecture?: string;
39
+ }
40
+ export interface AggregateIdentityRun {
41
+ case: string;
42
+ run: number;
43
+ identity_sha256: IdentityDigest;
44
+ }
45
+ export declare function createProjectEvalIdentity(input: CreateProjectEvalIdentityInput): ProjectEvalIdentity;
46
+ export declare function validateProjectEvalIdentity(value: unknown): ProjectEvalIdentity;
47
+ export declare function computeProjectEvalAggregateIdentity(runs: readonly AggregateIdentityRun[]): IdentityDigest;
48
+ export declare function validateProjectEvalAggregateIdentity(value: unknown, runs: readonly AggregateIdentityRun[]): IdentityDigest;
49
+ export declare function firstProjectEvalIdentityMismatch(left: ProjectEvalIdentity, right: ProjectEvalIdentity): string | null;
50
+ export declare function assertProjectEvalIdentitiesComparable(left: ProjectEvalIdentity, right: ProjectEvalIdentity, context?: string): void;
51
+ //# sourceMappingURL=project-eval-identity.d.ts.map
@@ -0,0 +1,341 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { posix, win32 } from 'node:path';
3
+ import { redactSensitiveValue, sensitiveEnvironmentValues, } from '../platform/sensitive-data.js';
4
+ export const PROJECT_EVAL_IDENTITY_SCHEMA_VERSION = '1.0';
5
+ const DIGEST = /^sha256:[0-9a-f]{64}$/u;
6
+ const MAX_DEPTH = 32;
7
+ const MAX_NODES = 500_000;
8
+ const MAX_STRING = 64 * 1024;
9
+ function fail(message) {
10
+ throw new Error(`Invalid Project Eval identity: ${message}`);
11
+ }
12
+ function codeUnitCompare(left, right) {
13
+ return left < right ? -1 : left > right ? 1 : 0;
14
+ }
15
+ function assertSerializable(value, path = '$', depth = 0, state = { nodes: 0 }, ancestry = new WeakSet()) {
16
+ state.nodes += 1;
17
+ if (state.nodes > MAX_NODES)
18
+ fail(`${path} exceeds object node limit`);
19
+ if (depth > MAX_DEPTH)
20
+ fail(`${path} exceeds object depth limit`);
21
+ if (value === null ||
22
+ typeof value === 'boolean' ||
23
+ typeof value === 'number') {
24
+ if (typeof value === 'number' && !Number.isFinite(value))
25
+ fail(`${path} contains a non-finite number`);
26
+ return;
27
+ }
28
+ if (typeof value === 'string') {
29
+ if (value.length > MAX_STRING)
30
+ fail(`${path} contains an oversized string`);
31
+ return;
32
+ }
33
+ if (typeof value === 'bigint' ||
34
+ typeof value === 'function' ||
35
+ typeof value === 'symbol' ||
36
+ value === undefined)
37
+ fail(`${path} contains an unsupported value`);
38
+ if (Array.isArray(value)) {
39
+ if (ancestry.has(value))
40
+ fail(`${path} contains a circular reference`);
41
+ ancestry.add(value);
42
+ try {
43
+ for (let index = 0; index < value.length; index += 1)
44
+ assertSerializable(value[index], `${path}[${index}]`, depth + 1, state, ancestry);
45
+ }
46
+ finally {
47
+ ancestry.delete(value);
48
+ }
49
+ return;
50
+ }
51
+ if (typeof value === 'object') {
52
+ const prototype = Object.getPrototypeOf(value);
53
+ if (prototype !== Object.prototype && prototype !== null)
54
+ fail(`${path} contains an unsupported object`);
55
+ if (ancestry.has(value))
56
+ fail(`${path} contains a circular reference`);
57
+ ancestry.add(value);
58
+ try {
59
+ for (const [key, child] of Object.entries(value)) {
60
+ if (key.length > 512)
61
+ fail(`${path} contains an oversized key`);
62
+ assertSerializable(child, `${path}.${key}`, depth + 1, state, ancestry);
63
+ }
64
+ }
65
+ finally {
66
+ ancestry.delete(value);
67
+ }
68
+ return;
69
+ }
70
+ fail(`${path} contains an unsupported value`);
71
+ }
72
+ function canonical(value) {
73
+ assertSerializable(value);
74
+ const ancestry = new WeakSet();
75
+ const serialize = (item) => {
76
+ if (item === null || typeof item !== 'object')
77
+ return JSON.stringify(item);
78
+ if (ancestry.has(item))
79
+ fail('contains a circular reference');
80
+ ancestry.add(item);
81
+ try {
82
+ if (Array.isArray(item))
83
+ return `[${item.map((child) => serialize(child)).join(',')}]`;
84
+ return `{${Object.entries(item)
85
+ .sort(([left], [right]) => codeUnitCompare(left, right))
86
+ .map(([key, child]) => `${JSON.stringify(key)}:${serialize(child)}`)
87
+ .join(',')}}`;
88
+ }
89
+ finally {
90
+ ancestry.delete(item);
91
+ }
92
+ };
93
+ return serialize(value);
94
+ }
95
+ function digest(value) {
96
+ return `sha256:${createHash('sha256').update(canonical(value), 'utf8').digest('hex')}`;
97
+ }
98
+ function string(value, path) {
99
+ if (typeof value !== 'string' ||
100
+ value.length === 0 ||
101
+ value.length > MAX_STRING)
102
+ fail(`${path} must be a bounded non-empty string`);
103
+ return value;
104
+ }
105
+ function digestField(value, path) {
106
+ if (typeof value !== 'string' || !DIGEST.test(value))
107
+ fail(`${path} must be a sha256 digest`);
108
+ return value;
109
+ }
110
+ function positiveInteger(value, path) {
111
+ if (!Number.isSafeInteger(value) || value < 1)
112
+ fail(`${path} must be a positive integer`);
113
+ return value;
114
+ }
115
+ function configurationSource(input) {
116
+ const c = input.case;
117
+ const sensitiveValues = sensitiveEnvironmentValues(c.execution.env);
118
+ const graders = c.graders.map((grader) => grader.type === 'tool_used' && !Number.isFinite(grader.max)
119
+ ? { ...grader, max: 'unbounded' }
120
+ : grader);
121
+ const configuration = {
122
+ case: {
123
+ name: c.name,
124
+ schema_version: c.schemaVersion,
125
+ },
126
+ execution: {
127
+ max_turns: c.execution.maxTurns,
128
+ timeout_seconds: c.execution.timeoutSeconds,
129
+ env: c.execution.env,
130
+ },
131
+ verification: {
132
+ enabled: input.runVerification,
133
+ definitions: c.verification,
134
+ },
135
+ graders,
136
+ expect: {
137
+ allowed_changed_paths: c.expect.allowedChangedPaths,
138
+ expected_changed_paths: c.expect.expectedChangedPaths,
139
+ forbidden_changed_paths: c.expect.forbiddenChangedPaths,
140
+ },
141
+ };
142
+ return normalizeConfiguration(redactSensitiveValue(configuration, sensitiveValues));
143
+ }
144
+ function normalizeConfiguration(value) {
145
+ if (typeof value === 'string')
146
+ return posix.isAbsolute(value) || win32.isAbsolute(value)
147
+ ? '[ABSOLUTE_PATH]'
148
+ : value;
149
+ if (Array.isArray(value))
150
+ return value.map(normalizeConfiguration);
151
+ if (value && typeof value === 'object')
152
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [
153
+ key,
154
+ normalizeConfiguration(child),
155
+ ]));
156
+ return value;
157
+ }
158
+ function corpusSource(sourceBefore) {
159
+ return {
160
+ files: Object.fromEntries(Object.entries(sourceBefore.files).map(([path, file]) => [
161
+ path,
162
+ {
163
+ hash: file.hash,
164
+ size: file.size,
165
+ mode: file.mode,
166
+ },
167
+ ])),
168
+ total_bytes: sourceBefore.totalBytes,
169
+ };
170
+ }
171
+ function freeze(value) {
172
+ if (value && typeof value === 'object') {
173
+ Object.freeze(value);
174
+ for (const child of Object.values(value))
175
+ freeze(child);
176
+ }
177
+ return value;
178
+ }
179
+ export function createProjectEvalIdentity(input) {
180
+ const provider = input.provider;
181
+ const runtimeBase = {
182
+ engine: 'praxis',
183
+ praxis_version: string(input.praxisVersion, 'praxis_version'),
184
+ node_version: string(input.nodeVersion ?? process.version, 'node_version'),
185
+ platform: string(input.platform ?? process.platform, 'platform'),
186
+ architecture: string(input.architecture ?? process.arch, 'architecture'),
187
+ };
188
+ const identityWithoutDigests = {
189
+ schema_version: PROJECT_EVAL_IDENTITY_SCHEMA_VERSION,
190
+ provider_id: string(provider.providerId, 'provider_id'),
191
+ profile_id: string(provider.profileId, 'profile_id'),
192
+ protocol: string(provider.protocol, 'protocol'),
193
+ endpoint_sha256: digest(string(provider.endpoint, 'endpoint')),
194
+ model_id: string(provider.modelId, 'model_id'),
195
+ configuration_sha256: digest(configurationSource(input)),
196
+ tools_sha256: digest(input.effectiveTools),
197
+ prompt_sha256: digest({
198
+ prompt: input.case.execution.prompt,
199
+ append_system_prompt: input.case.execution.appendSystemPrompt ?? null,
200
+ }),
201
+ corpus_sha256: digest(corpusSource(input.sourceBefore)),
202
+ runtime: {
203
+ ...runtimeBase,
204
+ runtime_sha256: digest(runtimeBase),
205
+ },
206
+ };
207
+ return freeze({
208
+ ...identityWithoutDigests,
209
+ identity_sha256: digest(identityWithoutDigests),
210
+ });
211
+ }
212
+ function validateRuntime(value, path) {
213
+ const runtime = value;
214
+ if (!runtime || typeof runtime !== 'object' || Array.isArray(runtime))
215
+ fail(`${path} must be an object`);
216
+ const runtimeKeys = [
217
+ 'engine',
218
+ 'praxis_version',
219
+ 'node_version',
220
+ 'platform',
221
+ 'architecture',
222
+ 'runtime_sha256',
223
+ ];
224
+ const runtimeUnknown = Object.keys(runtime).find((key) => !runtimeKeys.includes(key));
225
+ if (runtimeUnknown)
226
+ fail(`${path}.${runtimeUnknown} is not supported`);
227
+ if (runtime.engine !== 'praxis')
228
+ fail(`${path}.engine must be "praxis"`);
229
+ const validated = {
230
+ engine: 'praxis',
231
+ praxis_version: string(runtime.praxis_version, `${path}.praxis_version`),
232
+ node_version: string(runtime.node_version, `${path}.node_version`),
233
+ platform: string(runtime.platform, `${path}.platform`),
234
+ architecture: string(runtime.architecture, `${path}.architecture`),
235
+ runtime_sha256: digestField(runtime.runtime_sha256, `${path}.runtime_sha256`),
236
+ };
237
+ if (validated.runtime_sha256 !==
238
+ digest({
239
+ engine: validated.engine,
240
+ praxis_version: validated.praxis_version,
241
+ node_version: validated.node_version,
242
+ platform: validated.platform,
243
+ architecture: validated.architecture,
244
+ }))
245
+ fail(`${path}.runtime_sha256 does not match runtime fields`);
246
+ return validated;
247
+ }
248
+ export function validateProjectEvalIdentity(value) {
249
+ if (!value || typeof value !== 'object' || Array.isArray(value))
250
+ fail('must be an object');
251
+ const source = value;
252
+ const keys = [
253
+ 'schema_version',
254
+ 'provider_id',
255
+ 'profile_id',
256
+ 'protocol',
257
+ 'endpoint_sha256',
258
+ 'model_id',
259
+ 'configuration_sha256',
260
+ 'tools_sha256',
261
+ 'prompt_sha256',
262
+ 'corpus_sha256',
263
+ 'runtime',
264
+ 'identity_sha256',
265
+ ];
266
+ const unknown = Object.keys(source).find((key) => !keys.includes(key));
267
+ if (unknown)
268
+ fail(`${unknown} is not supported`);
269
+ if (source.schema_version !== PROJECT_EVAL_IDENTITY_SCHEMA_VERSION)
270
+ fail('schema_version must be "1.0"');
271
+ const validated = {
272
+ schema_version: PROJECT_EVAL_IDENTITY_SCHEMA_VERSION,
273
+ provider_id: string(source.provider_id, 'provider_id'),
274
+ profile_id: string(source.profile_id, 'profile_id'),
275
+ protocol: string(source.protocol, 'protocol'),
276
+ endpoint_sha256: digestField(source.endpoint_sha256, 'endpoint_sha256'),
277
+ model_id: string(source.model_id, 'model_id'),
278
+ configuration_sha256: digestField(source.configuration_sha256, 'configuration_sha256'),
279
+ tools_sha256: digestField(source.tools_sha256, 'tools_sha256'),
280
+ prompt_sha256: digestField(source.prompt_sha256, 'prompt_sha256'),
281
+ corpus_sha256: digestField(source.corpus_sha256, 'corpus_sha256'),
282
+ runtime: validateRuntime(source.runtime, 'runtime'),
283
+ identity_sha256: digestField(source.identity_sha256, 'identity_sha256'),
284
+ };
285
+ const withoutIdentity = { ...validated };
286
+ delete withoutIdentity.identity_sha256;
287
+ if (validated.identity_sha256 !== digest(withoutIdentity))
288
+ fail('identity_sha256 does not match identity fields');
289
+ return freeze(validated);
290
+ }
291
+ export function computeProjectEvalAggregateIdentity(runs) {
292
+ const entries = runs
293
+ .map((run) => ({
294
+ case: string(run.case, 'runs.case'),
295
+ run: positiveInteger(run.run, 'runs.run'),
296
+ identity_sha256: digestField(run.identity_sha256, 'runs.identity_sha256'),
297
+ }))
298
+ .sort((left, right) => {
299
+ const caseOrder = codeUnitCompare(left.case, right.case);
300
+ return caseOrder === 0 ? left.run - right.run : caseOrder;
301
+ });
302
+ return digest(entries);
303
+ }
304
+ export function validateProjectEvalAggregateIdentity(value, runs) {
305
+ const declared = digestField(value, 'identity_sha256');
306
+ const expected = computeProjectEvalAggregateIdentity(runs);
307
+ if (declared !== expected)
308
+ fail('aggregate identity_sha256 does not match runs');
309
+ return declared;
310
+ }
311
+ const comparableIdentityDimensions = [
312
+ ['provider_id', 'provider_id'],
313
+ ['profile_id', 'profile_id'],
314
+ ['protocol', 'protocol'],
315
+ ['endpoint_sha256', 'endpoint_sha256'],
316
+ ['model_id', 'model_id'],
317
+ ['configuration_sha256', 'configuration_sha256'],
318
+ ['tools_sha256', 'tools_sha256'],
319
+ ['prompt_sha256', 'prompt_sha256'],
320
+ ['corpus_sha256', 'corpus_sha256'],
321
+ ['runtime.engine', 'runtime.engine'],
322
+ ['runtime.node_version', 'runtime.node_version'],
323
+ ['runtime.platform', 'platform'],
324
+ ['runtime.architecture', 'architecture'],
325
+ ];
326
+ export function firstProjectEvalIdentityMismatch(left, right) {
327
+ for (const [field, label] of comparableIdentityDimensions) {
328
+ const read = (identity) => field.startsWith('runtime.')
329
+ ? identity.runtime[field.slice('runtime.'.length)]
330
+ : identity[field];
331
+ if (read(left) !== read(right))
332
+ return label;
333
+ }
334
+ return null;
335
+ }
336
+ export function assertProjectEvalIdentitiesComparable(left, right, context) {
337
+ const mismatch = firstProjectEvalIdentityMismatch(left, right);
338
+ if (mismatch)
339
+ throw new Error(`${context ? `${context}: ` : ''}non-comparable identity: ${mismatch} differs`);
340
+ }
341
+ //# sourceMappingURL=project-eval-identity.js.map
@@ -1,5 +1,6 @@
1
1
  import type { ModelUsage } from '../core/runtime.js';
2
- import { type EvalGraderResult, type EvalRuntimeFactory } from './eval-contract.js';
2
+ import { type EvalGraderResult, type IdentifiedEvalRuntimeFactory } from './eval-contract.js';
3
+ import { type ProjectEvalIdentity } from './project-eval-identity.js';
3
4
  import type { ProjectEvalCase } from './project-eval-schema.js';
4
5
  export type ProjectEvalTermination = 'timeout' | 'interrupted' | null;
5
6
  export interface ProjectEvalVerificationResult {
@@ -17,11 +18,12 @@ export interface ProjectEvalVerificationResult {
17
18
  error: string | null;
18
19
  }
19
20
  export interface ProjectEvalRunResult {
20
- schema_version: '1.0';
21
+ schema_version: '1.1';
21
22
  case: string;
22
23
  run: number;
23
24
  version: string;
24
- model: string | null;
25
+ model: string;
26
+ identity: ProjectEvalIdentity;
25
27
  passed: boolean;
26
28
  score: 0 | 1;
27
29
  turns: number;
@@ -44,6 +46,7 @@ export interface ProjectEvalRunResult {
44
46
  trace: 'trace.jsonl';
45
47
  workspace_diff: 'workspace-diff.json';
46
48
  verification: 'verification.json';
49
+ identity: 'identity.json';
47
50
  result: 'result.json';
48
51
  };
49
52
  error: string | null;
@@ -52,7 +55,7 @@ export interface ProjectEvalRunResult {
52
55
  }
53
56
  interface ProjectEvalRunOptions {
54
57
  case: ProjectEvalCase;
55
- factory: EvalRuntimeFactory;
58
+ factory: IdentifiedEvalRuntimeFactory;
56
59
  run: number;
57
60
  allowTools?: readonly string[];
58
61
  model?: string;
@@ -3,6 +3,7 @@ import { BoundedProcessRunner } from '../platform/bounded-process-runner.js';
3
3
  import { redactSensitiveValue, redactSensitiveText, sensitiveEnvironmentValues, } from '../platform/sensitive-data.js';
4
4
  import { gradeDeterministicEvalRun } from './eval-graders.js';
5
5
  import { normalizeEvalTraceEvent, resolveEvalAllowedTools, } from './eval-contract.js';
6
+ import { createProjectEvalIdentity, } from './project-eval-identity.js';
6
7
  import { cleanupProjectEvalWorkspace, createProjectEvalWorkspace, diffProjectEvalWorkspace, } from './project-eval-workspace.js';
7
8
  import { minimatch } from 'minimatch';
8
9
  import { join } from 'node:path';
@@ -117,6 +118,38 @@ export async function runProjectEvalCase(options) {
117
118
  let lastMessage = '';
118
119
  let graderError = null;
119
120
  let graders = [];
121
+ const effectiveModel = options.model ?? options.case.execution.model;
122
+ const factoryOptions = {
123
+ dataPlane: 'native',
124
+ cwd: workspace.cwd,
125
+ configRoot: workspace.config,
126
+ home: workspace.home,
127
+ maxTurns: options.case.execution.maxTurns,
128
+ pluginDirectories: [],
129
+ allowedTools,
130
+ ...(effectiveModel ? { model: effectiveModel } : {}),
131
+ ...(options.case.execution.appendSystemPrompt
132
+ ? { appendSystemPrompt: options.case.execution.appendSystemPrompt }
133
+ : {}),
134
+ addDirs: [],
135
+ env: options.case.execution.env,
136
+ };
137
+ let identity;
138
+ try {
139
+ const descriptor = await options.factory.identify(factoryOptions);
140
+ identity = createProjectEvalIdentity({
141
+ provider: descriptor,
142
+ case: options.case,
143
+ sourceBefore: workspace.sourceBefore,
144
+ effectiveTools: allowedTools,
145
+ runVerification: options.runVerification ?? false,
146
+ praxisVersion: options.version,
147
+ });
148
+ }
149
+ catch (error) {
150
+ await cleanupProjectEvalWorkspace(workspace.root).catch(() => undefined);
151
+ throw error;
152
+ }
120
153
  const runtimeController = new AbortController();
121
154
  let deadlineReached = false;
122
155
  const forwardAbort = () => runtimeController.abort(options.signal?.reason);
@@ -129,21 +162,7 @@ export async function runProjectEvalCase(options) {
129
162
  }, options.case.execution.timeoutSeconds * 1000);
130
163
  try {
131
164
  runtime = await options.factory.create({
132
- dataPlane: 'native',
133
- cwd: workspace.cwd,
134
- configRoot: workspace.config,
135
- home: workspace.home,
136
- maxTurns: options.case.execution.maxTurns,
137
- pluginDirectories: [],
138
- allowedTools,
139
- ...((options.model ?? options.case.execution.model)
140
- ? { model: options.model ?? options.case.execution.model }
141
- : {}),
142
- ...(options.case.execution.appendSystemPrompt
143
- ? { appendSystemPrompt: options.case.execution.appendSystemPrompt }
144
- : {}),
145
- addDirs: [],
146
- env: options.case.execution.env,
165
+ ...factoryOptions,
147
166
  eventSink: (event) => {
148
167
  if (traceOverflow)
149
168
  return;
@@ -332,6 +351,7 @@ export async function runProjectEvalCase(options) {
332
351
  await writeFileAtomically(join(runDirectory, 'trace.jsonl'), traceRecords.join('\n'));
333
352
  await writeFileAtomically(join(runDirectory, 'workspace-diff.json'), JSON.stringify({ schema_version: '1.0', ...workspaceDiff }, null, 2));
334
353
  await writeFileAtomically(join(runDirectory, 'verification.json'), JSON.stringify(verifications, null, 2));
354
+ await writeFileAtomically(join(runDirectory, 'identity.json'), JSON.stringify(identity, null, 2));
335
355
  }
336
356
  catch (error) {
337
357
  artifactError = errorText(error);
@@ -361,11 +381,12 @@ export async function runProjectEvalCase(options) {
361
381
  checks.find((item) => !item.passed)?.explanation ??
362
382
  null;
363
383
  const result = {
364
- schema_version: '1.0',
384
+ schema_version: '1.1',
365
385
  case: options.case.name,
366
386
  run: options.run,
367
387
  version: options.version,
368
- model: options.model ?? options.case.execution.model ?? null,
388
+ model: identity.model_id,
389
+ identity,
369
390
  passed,
370
391
  score: passed ? 1 : 0,
371
392
  turns,
@@ -381,6 +402,7 @@ export async function runProjectEvalCase(options) {
381
402
  trace: 'trace.jsonl',
382
403
  workspace_diff: 'workspace-diff.json',
383
404
  verification: 'verification.json',
405
+ identity: 'identity.json',
384
406
  result: 'result.json',
385
407
  },
386
408
  error: primaryError,
@@ -1,9 +1,10 @@
1
1
  import type { ModelUsage } from '../core/runtime.js';
2
- import type { EvalRuntimeFactory } from './eval-contract.js';
2
+ import type { IdentifiedEvalRuntimeFactory } from './eval-contract.js';
3
3
  import { type ProjectEvalRunResult } from './project-eval-runner.js';
4
+ import { type ProjectEvalIdentity } from './project-eval-identity.js';
4
5
  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
6
  export interface ProjectEvalDependencies {
6
- runtimeFactory: EvalRuntimeFactory;
7
+ runtimeFactory: IdentifiedEvalRuntimeFactory;
7
8
  version?: string;
8
9
  configRoot: string;
9
10
  }
@@ -31,7 +32,7 @@ export interface ProjectEvalUsageTotals {
31
32
  export interface ProjectEvalRunSummary {
32
33
  case: string;
33
34
  run: number;
34
- model: string | null;
35
+ model: string;
35
36
  passed: boolean;
36
37
  score: 0 | 1;
37
38
  turns: number;
@@ -50,9 +51,10 @@ export interface ProjectEvalRunSummary {
50
51
  retries: number;
51
52
  error: string | null;
52
53
  artifact_dir: string;
54
+ identity: ProjectEvalIdentity;
53
55
  }
54
56
  export interface ProjectEvalAggregate {
55
- schema_version: '1.0';
57
+ schema_version: '1.1';
56
58
  version: string;
57
59
  start: string;
58
60
  duration_ms: number;
@@ -89,6 +91,7 @@ export interface ProjectEvalAggregate {
89
91
  };
90
92
  partial: boolean;
91
93
  interrupted: boolean;
94
+ identity_sha256: `sha256:${string}`;
92
95
  runs: readonly ProjectEvalRunSummary[];
93
96
  }
94
97
  export declare function parseProjectEvalOptions(argv: readonly string[]): ProjectEvalOptions;
@@ -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 { computeProjectEvalAggregateIdentity, } from './project-eval-identity.js';
4
5
  import { executeProjectEvalCompareCommand } from './project-eval-comparison.js';
5
6
  import { discoverProjectEvalCases } from './project-eval-schema.js';
6
7
  export const PROJECT_EVAL_HELP = `Usage: praxis eval [options] <target>
@@ -134,6 +135,7 @@ function runSummary(result, outputDirectory) {
134
135
  retries: result.retries,
135
136
  error: result.error,
136
137
  artifact_dir: relative(outputDirectory, join(outputDirectory, result.case, `run-${result.run}`)).replaceAll('\\', '/'),
138
+ identity: result.identity,
137
139
  };
138
140
  }
139
141
  export async function executeProjectEvalCommand(argv, io, dependencies, callerCwd = process.cwd(), signal) {
@@ -184,14 +186,18 @@ export async function executeProjectEvalCommand(argv, io, dependencies, callerCw
184
186
  }
185
187
  const passed = results.filter((result) => result.passed).length;
186
188
  const knownCostResults = results.filter((result) => result.cost_known);
189
+ const summaries = results.map((result) => runSummary(result, outputDirectory));
187
190
  const aggregate = {
188
- schema_version: '1.0',
191
+ schema_version: '1.1',
189
192
  version: dependencies.version ?? 'unknown',
190
193
  start: new Date(started).toISOString(),
191
194
  duration_ms: Date.now() - started,
192
195
  target,
193
196
  output_dir: outputDirectory,
194
- model: options.model ?? null,
197
+ model: summaries.length > 0 &&
198
+ summaries.every((summary) => summary.model === summaries[0]?.model)
199
+ ? (summaries[0]?.model ?? null)
200
+ : null,
195
201
  case_count: cases.length,
196
202
  planned_run_count: plannedRunCount,
197
203
  completed_run_count: results.length,
@@ -226,7 +232,12 @@ export async function executeProjectEvalCommand(argv, io, dependencies, callerCw
226
232
  },
227
233
  partial: interrupted || results.length < plannedRunCount,
228
234
  interrupted,
229
- runs: results.map((result) => runSummary(result, outputDirectory)),
235
+ identity_sha256: computeProjectEvalAggregateIdentity(summaries.map((summary) => ({
236
+ case: summary.case,
237
+ run: summary.run,
238
+ identity_sha256: summary.identity.identity_sha256,
239
+ }))),
240
+ runs: summaries,
230
241
  };
231
242
  await writeFileAtomically(join(outputDirectory, 'aggregate-result.json'), JSON.stringify(aggregate, null, 2));
232
243
  if (options.json)
@@ -1,6 +1,6 @@
1
1
  import type { ModelProvider, ModelThinkingConfig } from '../core/runtime.js';
2
2
  import { type CodexOAuthVault } from './codex-oauth.js';
3
- import { type ProviderProtocol, type ProviderTarget } from './provider-settings.js';
3
+ import { type ResolveProviderTargetOptions, type ProviderProtocol, type ProviderTarget } from './provider-settings.js';
4
4
  import { type AnthropicModelAliasOverrides } from './anthropic-model-alias.js';
5
5
  import type { ProviderCredentialSourceMetadata, ProviderCredentialReader, ResolvedProviderCredential } from './provider-auth.js';
6
6
  import { parseProviderEnvironment, type ContextEnvironment } from './environment.js';
@@ -44,6 +44,7 @@ export interface ResolveProviderRegistryOptions {
44
44
  fetchImplementation?: typeof fetch;
45
45
  }
46
46
  export type ProviderRegistrySourceMetadata = ProviderCredentialSourceMetadata;
47
+ export declare function resolveProviderRuntimeTarget(options: ResolveProviderTargetOptions): Promise<ProviderTarget>;
47
48
  export interface ProviderRegistry {
48
49
  readonly target: ProviderTarget;
49
50
  readonly credentialSource: ProviderRegistrySourceMetadata;
@@ -19,6 +19,24 @@ export class ProviderRegistryError extends Error {
19
19
  this.code = code;
20
20
  }
21
21
  }
22
+ function effectiveProviderTarget(target, overrides) {
23
+ if (target.providerId !== 'anthropic' ||
24
+ target.protocol !== 'anthropic-messages')
25
+ return target;
26
+ return {
27
+ ...target,
28
+ modelId: resolveAnthropicModelAlias(target.modelId, overrides),
29
+ };
30
+ }
31
+ export async function resolveProviderRuntimeTarget(options) {
32
+ const environment = options.environment ?? process.env;
33
+ const target = await resolveProviderTarget({ ...options, environment });
34
+ const aliases = target.providerId === 'anthropic' &&
35
+ target.protocol === 'anthropic-messages'
36
+ ? anthropicModelAliasOverridesFromEnvironment(environment)
37
+ : undefined;
38
+ return effectiveProviderTarget(target, aliases);
39
+ }
22
40
  export function resolveProviderContextWindowTokens(options) {
23
41
  if (options.protocol === 'anthropic-messages')
24
42
  return resolveAnthropicModelSpec(options.modelId, options.explicitContextWindowTokens).contextWindowTokens;
@@ -113,8 +131,10 @@ class NativeProviderRegistry {
113
131
  : { fetchImplementation: options.fetchImplementation });
114
132
  }
115
133
  }
116
- create(modelId = this.target.modelId) {
117
- const target = this.resolveTarget({ ...this.target, modelId });
134
+ create(modelId) {
135
+ const target = modelId === undefined
136
+ ? this.target
137
+ : this.resolveTarget({ ...this.target, modelId });
118
138
  if (target.protocol === 'codex-subscription') {
119
139
  if (!this.codexManager)
120
140
  throw new ProviderAuthenticationError('invalid_credential', 'Provider authentication failed: Codex subscription credentials are unavailable');
@@ -239,13 +259,7 @@ class NativeProviderRegistry {
239
259
  return override !== undefined && override.trim().length > 0;
240
260
  }
241
261
  resolveTarget(target) {
242
- if (target.providerId !== 'anthropic' ||
243
- target.protocol !== 'anthropic-messages')
244
- return target;
245
- return {
246
- ...target,
247
- modelId: resolveAnthropicModelAlias(target.modelId, this.options.anthropicModelAliasOverrides),
248
- };
262
+ return effectiveProviderTarget(target, this.options.anthropicModelAliasOverrides);
249
263
  }
250
264
  withDeadline(provider) {
251
265
  const environment = this.options.providerEnvironment;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.64.1",
3
+ "version": "0.65.0",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",