@wix/pathgrade 1.0.37 → 1.0.39

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.
Files changed (53) hide show
  1. package/README.md +3 -1
  2. package/dist/adapter-kit/index.d.ts +2 -2
  3. package/dist/adapter-kit/index.js +1 -1
  4. package/dist/adapters/jest/lifecycle.js +4 -2
  5. package/dist/adapters/node-test/index.js +4 -2
  6. package/dist/agents/claude/sdk-message-projector.js +4 -5
  7. package/dist/agents/claude/tool-permission-bridge.js +2 -1
  8. package/dist/agents/codex-app-server/item-projection.js +2 -2
  9. package/dist/agents/codex-app-server/mcp-approval-correlator.js +3 -2
  10. package/dist/agents/opencode.js +4 -5
  11. package/dist/commands/report.js +5 -25
  12. package/dist/internal/direct-mcp-v2/acp-author-projector.js +20 -7
  13. package/dist/reporters/cli.js +20 -1
  14. package/dist/reporters/github-comment.js +18 -7
  15. package/dist/reporters/loader.d.ts +4 -0
  16. package/dist/reporters/loader.js +26 -9
  17. package/dist/reporting/comparison-contract.js +20 -6
  18. package/dist/reporting/core.js +18 -8
  19. package/dist/reporting/report-parser.js +57 -2
  20. package/dist/reporting/types.d.ts +2 -1
  21. package/dist/runners/adapter.d.ts +4 -2
  22. package/dist/runners/lifecycle-hooks.js +6 -1
  23. package/dist/runners/orchestrator.js +3 -4
  24. package/dist/runners/repeated-invocation.js +2 -5
  25. package/dist/runners/report-projection.js +1 -0
  26. package/dist/runners/vitest-lifecycle.d.ts +1 -0
  27. package/dist/runners/vitest-lifecycle.js +21 -4
  28. package/dist/sdk/agent-flow.d.ts +51 -0
  29. package/dist/sdk/agent-flow.js +23 -0
  30. package/dist/sdk/agent.js +10 -1
  31. package/dist/sdk/evaluate.d.ts +2 -1
  32. package/dist/sdk/evaluate.js +114 -4
  33. package/dist/sdk/index.d.ts +4 -2
  34. package/dist/sdk/index.js +2 -2
  35. package/dist/sdk/judge-prompt-builder.js +11 -7
  36. package/dist/sdk/lifecycle.d.ts +3 -1
  37. package/dist/sdk/lifecycle.js +68 -5
  38. package/dist/sdk/mcp-event-input.d.ts +1 -0
  39. package/dist/sdk/mcp-event-input.js +3 -0
  40. package/dist/sdk/mcp-evidence.js +16 -3
  41. package/dist/sdk/mcp-safety.js +2 -2
  42. package/dist/sdk/result-capture.d.ts +14 -1
  43. package/dist/sdk/result-capture.js +49 -0
  44. package/dist/sdk/scripted-mcp-events.js +3 -2
  45. package/dist/sdk/tool-event-log.js +18 -3
  46. package/dist/sdk/tool-event-secrets.d.ts +4 -0
  47. package/dist/sdk/tool-event-secrets.js +33 -2
  48. package/dist/sdk/types.d.ts +6 -0
  49. package/dist/tool-event-results.d.ts +3 -0
  50. package/dist/tool-event-results.js +153 -23
  51. package/dist/types.d.ts +13 -7
  52. package/docs/agent-flow-evaluation.md +31 -0
  53. package/package.json +3 -2
package/README.md CHANGED
@@ -164,6 +164,8 @@ npx pathgrade run
164
164
  - **Scorer**: a function or judge that evaluates output or behavior
165
165
  - **Evaluation**: the aggregated result of one or more scorers, returned as a score from `0.0` to `1.0`
166
166
 
167
+ For protocol-neutral multi-agent graphs, see [Evaluating agent flows](docs/agent-flow-evaluation.md).
168
+
167
169
  ## Scorers
168
170
 
169
171
  Scorers evaluate the agent's output and behavior. `evaluate()` runs all scorers and computes a weighted average between `0.0` and `1.0`.
@@ -459,7 +461,7 @@ export default {
459
461
 
460
462
  Pathgrade reads `pathgrade.config.*` for CLI and affected-selection behavior. `runner.adapter` and `--adapter=<name|path>` select the runner; `--adapter` wins over config. `attempts` defaults to `1` and must be a positive integer. Built-in adapters `vitest`, `jest`, and `node-test` support repeated attempts; third-party invocation adapters must advertise `supportsRepeatedAttempts: true` and produce the normalized child snapshot contract.
461
463
 
462
- New reports use schema version 2. They preserve every attempt with `case_id`, `attempt_id`, and `attempt_index`; include the display-safe `runner_outcome` that determined binary success; publish `mean_reward` for partial scores; and publish finite-sample pass@k only for complete binary attempts. Runner assertions, runner diagnostics, and native runner references remain in the normalized run model and are not copied into public report artifacts; evaluation diagnostics retain their existing report behavior. A partial reward or incomplete attempt makes pass@k explicitly unavailable. Version-1 reports remain readable, but Pathgrade no longer computes pass@k or pass^k by pooling heterogeneous cases.
464
+ New reports use schema version 2. They preserve every attempt with `case_id`, `attempt_id`, and `attempt_index`; include the display-safe `runner_outcome` that determined binary success; publish `mean_reward` for partial scores; and publish finite-sample pass@k only for complete binary attempts. `runner_status` and `threshold_status` expose the two gates independently, and canonical `status` passes only when runner assertions pass and any configured threshold passes. Runner assertions, runner diagnostics, and native runner references remain in the normalized run model and are not copied into public report artifacts; evaluation diagnostics retain their existing report behavior. A partial reward or incomplete attempt makes pass@k explicitly unavailable. Version-1 and older version-2 reports remain readable with missing gate fields derived during loading, but Pathgrade no longer computes pass@k or pass^k by pooling heterogeneous cases.
463
465
 
464
466
  Third-party runner adapters are supported through `@wix/pathgrade/adapter-kit`. Adapter names resolve as follows:
465
467
 
@@ -13,5 +13,5 @@ export { getPathgradeDir } from '../reporters/results-path.js';
13
13
  export { printReportSummary } from '../reporters/report-summary.js';
14
14
  export { fmt } from '../utils/cli.js';
15
15
  export { getCurrentCaseContext, installCaseContextProvider, runWithCaseContext, type CaseContext, type CaseContextProvider, type CaseContextProviderHandle, type CaseContextScope, type CurrentCaseContext, } from '../sdk/case-context.js';
16
- export { subscribeToEvalResults, type EvalResultObserver, type ResultObserverHandle, type ResultObserverOptions, type ResultObserverOwner, } from '../sdk/result-capture.js';
17
- export type { Agent, PathgradeTestMeta, RecordedEvalResult, } from '../sdk/types.js';
16
+ export { subscribeToEvalResults, subscribeToEvaluationResults, type EvalResultObserver, type ResultObserverHandle, type ResultObserverOptions, type ResultObserverOwner, type EvaluationResultEvent, type EvaluationResultObserver, } from '../sdk/result-capture.js';
17
+ export type { Agent, AgentFlowTrace, PathgradeTestMeta, RecordedEvalResult, } from '../sdk/types.js';
@@ -11,4 +11,4 @@ export { getPathgradeDir } from '../reporters/results-path.js';
11
11
  export { printReportSummary } from '../reporters/report-summary.js';
12
12
  export { fmt } from '../utils/cli.js';
13
13
  export { getCurrentCaseContext, installCaseContextProvider, runWithCaseContext, } from '../sdk/case-context.js';
14
- export { subscribeToEvalResults, } from '../sdk/result-capture.js';
14
+ export { subscribeToEvalResults, subscribeToEvaluationResults, } from '../sdk/result-capture.js';
@@ -1,4 +1,4 @@
1
- import { installCaseContextProvider, subscribeToEvalResults, createRunnerLifecycleHooks, } from '@wix/pathgrade/adapter-kit';
1
+ import { installCaseContextProvider, subscribeToEvaluationResults, createRunnerLifecycleHooks, } from '@wix/pathgrade/adapter-kit';
2
2
  import { appendJestMetadata } from './metadata.js';
3
3
  import { jestCaseId } from './results.js';
4
4
  const metadataByCaseId = new Map();
@@ -58,5 +58,7 @@ function resetJestLifecycleMetadata() {
58
58
  metadataByCaseId.clear();
59
59
  }
60
60
  function defaultSubscribeToResults(callback) {
61
- return subscribeToEvalResults(({ result, agent }) => callback({ result, agent }), { owner: 'adapter', key: 'jest-lifecycle' });
61
+ return subscribeToEvaluationResults(event => callback(event.sourceKind === 'flow'
62
+ ? { result: event.result, flow: event.flow, case: event.case }
63
+ : { result: event.result, agent: event.agent, case: event.case }), { owner: 'adapter', key: 'jest-lifecycle' });
62
64
  }
@@ -2,7 +2,7 @@ import nodeTest from 'node:test';
2
2
  import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
- import { subscribeToEvalResults } from '../../sdk/result-capture.js';
5
+ import { subscribeToEvaluationResults } from '../../sdk/result-capture.js';
6
6
  import { createRunnerLifecycleHooks } from '../../runners/lifecycle-hooks.js';
7
7
  const lifecycle = createRunnerLifecycleHooks();
8
8
  let subscribed = false;
@@ -69,7 +69,9 @@ function installResultCapture() {
69
69
  if (subscribed)
70
70
  return;
71
71
  subscribed = true;
72
- subscribeToEvalResults(event => lifecycle.onResult(event), {
72
+ subscribeToEvaluationResults(event => lifecycle.onResult(event.sourceKind === 'flow'
73
+ ? { result: event.result, flow: event.flow, case: event.case }
74
+ : { result: event.result, agent: event.agent, case: event.case }), {
73
75
  owner: 'adapter',
74
76
  key: 'node-test-lifecycle',
75
77
  });
@@ -22,10 +22,9 @@ const SDK_ERROR_SUBTYPES = [
22
22
  'error_max_structured_output_retries',
23
23
  ];
24
24
  import { TOOL_NAME_MAP, buildSummary, enrichSkillEvents } from '../../tool-events.js';
25
- import { sanitizePersistenceValue } from '../../tool-event-results.js';
25
+ import { sanitizePersistenceValue, sanitizeUntrustedPersistenceValue } from '../../tool-event-results.js';
26
26
  import { attachTurnResultSensitiveValues } from '../../sdk/turn-result-secrets.js';
27
- import { attachToolEventSensitiveValues } from '../../sdk/tool-event-secrets.js';
28
- import { attachOriginalMcpInput } from '../../sdk/mcp-event-input.js';
27
+ import { attachLiveMcpInput, attachToolEventSensitiveValues } from '../../sdk/tool-event-secrets.js';
29
28
  import { parseClaudeSdkMcpToolName } from './mcp-tool-name.js';
30
29
  import { applyObservedToolResult, extractObservedToolResults, } from './tool-results.js';
31
30
  export function projectSdkMessages(input) {
@@ -145,7 +144,7 @@ export function projectSdkMessages(input) {
145
144
  ...(input.deniedMcpEvents?.all() ?? []),
146
145
  ]);
147
146
  const finalToolEvents = prependSlashCommandSkillEvent(enriched, input.firstMessage, initSkills).map((event) => attachToolEventSensitiveValues(event, input.sensitiveValues ?? []));
148
- const traceOutput = sanitizePersistenceValue(input.messages, input.sensitiveValues)
147
+ const traceOutput = sanitizeUntrustedPersistenceValue(input.messages, input.sensitiveValues)
149
148
  .map((message) => JSON.stringify(message))
150
149
  .join('\n');
151
150
  const result = {
@@ -216,7 +215,7 @@ function buildToolEvent(block, turnNumber, answerStore, mcpServerNames, deniedMc
216
215
  tool: mcpTool.tool,
217
216
  status: 'incomplete',
218
217
  };
219
- return attachOriginalMcpInput({
218
+ return attachLiveMcpInput({
220
219
  action: 'mcp_tool_call',
221
220
  provider: 'claude',
222
221
  providerToolName: normalizedProviderToolName,
@@ -3,6 +3,7 @@ import { createAskUserBridge, } from './ask-user-bridge.js';
3
3
  import { parseClaudeSdkMcpToolName } from './mcp-tool-name.js';
4
4
  import { canonicalizeJson } from '../../core/canonical-json.js';
5
5
  import { buildScriptedMcpApprovalEvent, buildScriptedMcpDeniedCallEvent, } from '../../sdk/scripted-mcp-events.js';
6
+ import { attachLiveMcpInput } from '../../sdk/tool-event-secrets.js';
6
7
  export function createClaudeToolPermissionBridge(deps) {
7
8
  const askUserBridge = createAskUserBridge({
8
9
  askBus: deps.askBus,
@@ -149,5 +150,5 @@ function recordDeniedMcpEvent(opts) {
149
150
  input: opts.input,
150
151
  })).slice(0, 200),
151
152
  };
152
- opts.store.record(opts.toolUseId, event);
153
+ opts.store.record(opts.toolUseId, attachLiveMcpInput(event, opts.input));
153
154
  }
@@ -1,6 +1,6 @@
1
1
  import { buildSummary, extractSkillNameFromPath, inferCodexExecAction, } from '../../tool-events.js';
2
2
  import { sanitizeToolEventResult } from '../../tool-event-results.js';
3
- import { attachOriginalMcpInput } from '../../sdk/mcp-event-input.js';
3
+ import { attachLiveMcpInput } from '../../sdk/tool-event-secrets.js';
4
4
  export function projectItemIntoTurn(item, turn, sensitiveValues, timing = {}) {
5
5
  if (item.type === 'agentMessage') {
6
6
  const message = item;
@@ -76,7 +76,7 @@ function projectMcpCall(call, turn, sensitiveValues, timing) {
76
76
  const status = call.status === 'completed' && error === undefined
77
77
  ? 'completed' : call.status === 'failed' || error !== undefined ? 'error' : 'incomplete';
78
78
  const resultContent = call.result === undefined ? undefined : JSON.stringify(call.result);
79
- turn.nonAskToolEvents.push(attachOriginalMcpInput({
79
+ turn.nonAskToolEvents.push(attachLiveMcpInput({
80
80
  action: 'mcp_tool_call', provider: 'codex', providerToolName, toolUseId: call.id,
81
81
  turnNumber: turn.turnNumber, status, ...projectToolTiming(timing, finiteNumber(call.durationMs)),
82
82
  arguments: { ...args, server: call.server, tool: call.tool, status: call.status ?? 'unknown' },
@@ -1,6 +1,7 @@
1
1
  import { decideMcpToolCall, redactMcpSecrets, } from '../../sdk/mcp-safety.js';
2
2
  import { canonicalizeJson } from '../../core/canonical-json.js';
3
3
  import { buildScriptedMcpApprovalEvent, buildScriptedMcpDeniedCallEvent, } from '../../sdk/scripted-mcp-events.js';
4
+ import { attachLiveMcpInput } from '../../sdk/tool-event-secrets.js';
4
5
  function isRecord(value) {
5
6
  return !!value && typeof value === 'object' && !Array.isArray(value);
6
7
  }
@@ -64,7 +65,7 @@ export function queuePolicyDeniedMcpToolCall(turn, request, decision, rawParams)
64
65
  export function buildPolicyDeniedMcpToolEvent(turnNumber, pending, terminal) {
65
66
  const args = redactMcpSecrets(pending.request.arguments);
66
67
  const providerToolName = `${pending.request.serverName}.${pending.request.toolName}`;
67
- return {
68
+ return attachLiveMcpInput({
68
69
  action: 'mcp_tool_call', provider: 'codex', providerToolName,
69
70
  ...(terminal ? { toolUseId: terminal.id } : {}),
70
71
  turnNumber, status: 'error',
@@ -79,7 +80,7 @@ export function buildPolicyDeniedMcpToolEvent(turnNumber, pending, terminal) {
79
80
  },
80
81
  summary: `MCP tool ${providerToolName} policy_denied`, confidence: 'high',
81
82
  rawSnippet: JSON.stringify(redactMcpSecrets(terminal ? { approval: pending.rawParams, terminal } : pending.rawParams)),
82
- };
83
+ }, pending.request.arguments);
83
84
  }
84
85
  export function consumeMatchingMcpDenial(turn, serverName, toolName, args) {
85
86
  const argsKey = canonicalizeJson(args);
@@ -3,12 +3,11 @@ import * as path from 'node:path';
3
3
  import fs from 'fs-extra';
4
4
  import { BaseAgent, getRuntimeEnv, getWorkspacePath, } from '../types.js';
5
5
  import { buildSummary, enrichSkillEvents } from '../tool-events.js';
6
- import { collectSensitiveEnvValues, sanitizePersistenceValue, sanitizeToolEventResult, } from '../tool-event-results.js';
6
+ import { collectSensitiveEnvValues, sanitizeToolEventResult, sanitizeUntrustedPersistenceValue, } from '../tool-event-results.js';
7
7
  import { readStagedMcpServers } from '../providers/mcp-config.js';
8
8
  import { removeSandboxRoot } from '../providers/sandbox-lifecycle.js';
9
9
  import { attachTurnResultSensitiveValues, cloneTurnResultWithSensitiveValues } from '../sdk/turn-result-secrets.js';
10
- import { attachOriginalMcpInput } from '../sdk/mcp-event-input.js';
11
- import { attachToolEventSensitiveValues } from '../sdk/tool-event-secrets.js';
10
+ import { attachLiveMcpInput, attachToolEventSensitiveValues } from '../sdk/tool-event-secrets.js';
12
11
  import { currentOpenCodePlatformKey, OPENCODE_RUNTIME_LOCK, } from './opencode/contract.js';
13
12
  import { OpenCodeRuntimePolicy, OPENCODE_PERMISSION } from './opencode/runtime-policy.js';
14
13
  import { killOpenCodeProcessGroup, registerOpenCodeProcessGroup, unregisterOpenCodeProcessGroup, } from './opencode/process-groups.js';
@@ -217,7 +216,7 @@ export function parseOpenCodeOutput(stdout, processResult, mcpToolNames, sensiti
217
216
  rawSnippet: JSON.stringify({ tool, status: state.status, input }).slice(0, 2_000),
218
217
  };
219
218
  toolEvents.push(action === 'mcp_tool_call' && input
220
- ? attachOriginalMcpInput(toolEvent, input)
219
+ ? attachLiveMcpInput(toolEvent, input)
221
220
  : toolEvent);
222
221
  sanitizedTrace.push({ type, tool, status, input });
223
222
  continue;
@@ -256,7 +255,7 @@ export function parseOpenCodeOutput(stdout, processResult, mcpToolNames, sensiti
256
255
  if (stepFinishCount === 0)
257
256
  throw new Error('OpenCode protocol error: missing step_finish');
258
257
  const assistantMessage = textParts.join('');
259
- const traceOutput = sanitizePersistenceValue(sanitizedTrace, sensitiveValues)
258
+ const traceOutput = sanitizeUntrustedPersistenceValue(sanitizedTrace, sensitiveValues)
260
259
  .map((event) => JSON.stringify(event))
261
260
  .join('\n');
262
261
  return {
@@ -20,6 +20,7 @@
20
20
  import * as path from 'path';
21
21
  import fs from 'fs-extra';
22
22
  import { readSidecar } from '../affected/sidecar.js';
23
+ import { parsePathgradeReport } from '../reporting/report-parser.js';
23
24
  import { formatNoAffectedEvalsMarkdown, formatReportMarkdown, MISSING_RESULTS_BODY, postOrUpdateComment, resolvePrContext, } from '../reporters/github-comment.js';
24
25
  const DEFAULT_RESULTS_PATH = path.join('.pathgrade', 'results.json');
25
26
  /**
@@ -39,38 +40,17 @@ function resolveCommentId(explicit) {
39
40
  return workflow;
40
41
  return 'default';
41
42
  }
42
- function isPathgradeReport(value) {
43
- if (!value || typeof value !== 'object')
44
- return false;
45
- const v = value;
46
- return ((v.version === 1 || v.version === 2) &&
47
- typeof v.overall_pass_rate === 'number' &&
48
- (v.status === 'pass' || v.status === 'fail') &&
49
- Array.isArray(v.groups) &&
50
- (v.version === 1 || (typeof v.overall_mean_reward === 'number'
51
- && hasValidAttemptCounts(v)
52
- && v.groups.every(group => typeof group.mean_reward === 'number'))));
53
- }
54
- function hasValidAttemptCounts(report) {
55
- const requested = report.attempts_requested;
56
- const completed = report.attempts_completed;
57
- return typeof requested === 'number'
58
- && Number.isSafeInteger(requested)
59
- && requested >= 1
60
- && typeof completed === 'number'
61
- && Number.isSafeInteger(completed)
62
- && completed >= 0
63
- && completed <= requested;
64
- }
65
43
  async function loadReport(resolvedPath) {
66
44
  if (!(await fs.pathExists(resolvedPath))) {
67
45
  throw new Error(`results file not found at ${resolvedPath}`);
68
46
  }
69
47
  const raw = await fs.readJSON(resolvedPath);
70
- if (!isPathgradeReport(raw)) {
48
+ try {
49
+ return parsePathgradeReport(raw);
50
+ }
51
+ catch {
71
52
  throw new Error(`results file at ${resolvedPath} is not a valid pathgrade report`);
72
53
  }
73
- return raw;
74
54
  }
75
55
  function printMarkdownAndPassRate(markdown, passRate) {
76
56
  // Strip trailing newlines so we control spacing precisely:
@@ -1,5 +1,7 @@
1
1
  import { buildSummary } from '../../tool-events.js';
2
- import { sanitizePersistenceValue, sanitizeToolEventResult } from '../../tool-event-results.js';
2
+ import { sanitizePersistenceValue, sanitizeToolEventResult, sanitizeUntrustedPersistenceValue, } from '../../tool-event-results.js';
3
+ import { getOriginalMcpInput } from '../../sdk/mcp-event-input.js';
4
+ import { attachLiveMcpInput, attachToolEventSensitiveValues } from '../../sdk/tool-event-secrets.js';
3
5
  export const ACP_AUTHOR_TRACE_MAX_CHARS = 64 * 1024;
4
6
  function object(value) {
5
7
  return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
@@ -92,7 +94,7 @@ export function projectAcpAuthorTurn(input) {
92
94
  sessionUpdate: current.sessionUpdate,
93
95
  title: current.title,
94
96
  ...(current.status ? { status: current.status } : {}),
95
- ...(current.rawInput ? { rawInput: current.rawInput } : {}),
97
+ ...(current.rawInput ? { rawInput: sanitizeUntrustedPersistenceValue(current.rawInput, redactionValues) } : {}),
96
98
  });
97
99
  if (current.status !== 'completed' && current.status !== 'failed') {
98
100
  if (current.toolCallId)
@@ -114,7 +116,7 @@ export function projectAcpAuthorTurn(input) {
114
116
  toolCalls.set(current.toolCallId, { ...correlated, terminalFingerprint });
115
117
  const normalized = input.normalizeToolName(correlated.title);
116
118
  const resultText = textValue(correlated.status === 'completed' ? correlated.output : correlated.error);
117
- toolEvents.push({
119
+ const event = {
118
120
  action: normalized.action,
119
121
  provider: input.provider,
120
122
  providerToolName: normalized.providerToolName,
@@ -124,18 +126,21 @@ export function projectAcpAuthorTurn(input) {
124
126
  ...(resultText !== undefined ? { result: sanitizeToolEventResult({ content: resultText }, redactionValues) } : {}),
125
127
  summary: buildSummary(normalized.action, normalized.providerToolName, correlated.rawInput),
126
128
  confidence: normalized.action === 'unknown' ? 'low' : 'high',
127
- rawSnippet: JSON.stringify(sanitizePersistenceValue({ title: normalized.providerToolName, status: correlated.status, rawInput: correlated.rawInput }, redactionValues)).slice(0, 2_000),
128
- });
129
+ rawSnippet: JSON.stringify(sanitizeUntrustedPersistenceValue({ title: normalized.providerToolName, status: correlated.status, rawInput: correlated.rawInput }, redactionValues)).slice(0, 2_000),
130
+ };
131
+ toolEvents.push(normalized.action === 'mcp_tool_call' && correlated.rawInput
132
+ ? attachLiveMcpInput(event, correlated.rawInput)
133
+ : event);
129
134
  }
130
135
  const assistantMessage = sanitizePersistenceValue(assistantChunks.join(''), redactionValues);
131
- const rawTrace = JSON.stringify(sanitizePersistenceValue({
136
+ const rawTrace = JSON.stringify(sanitizeUntrustedPersistenceValue({
132
137
  terminal: { stopReason: input.terminal.stopReason },
133
138
  updates: trace,
134
139
  }, redactionValues));
135
140
  const traceOutput = rawTrace.length > ACP_AUTHOR_TRACE_MAX_CHARS
136
141
  ? `${rawTrace.slice(0, ACP_AUTHOR_TRACE_MAX_CHARS - 1)}…`
137
142
  : rawTrace;
138
- return sanitizePersistenceValue({
143
+ const result = sanitizePersistenceValue({
139
144
  rawOutput: traceOutput,
140
145
  traceOutput,
141
146
  assistantMessage,
@@ -144,4 +149,12 @@ export function projectAcpAuthorTurn(input) {
144
149
  exitCode: input.terminal.stopReason === 'end_turn' && !providerError ? 0 : 1,
145
150
  toolEvents,
146
151
  }, redactionValues);
152
+ result.toolEvents.forEach((event, index) => {
153
+ const originalInput = toolEvents[index] ? getOriginalMcpInput(toolEvents[index]) : undefined;
154
+ if (event.action === 'mcp_tool_call' && originalInput) {
155
+ attachLiveMcpInput(event, originalInput);
156
+ attachToolEventSensitiveValues(event, redactionValues);
157
+ }
158
+ });
159
+ return result;
147
160
  }
@@ -19,7 +19,8 @@ export async function runCliPreview(resultsDir, opts) {
19
19
  console.log(`\n${fmt.bold('pathgrade preview')} ${fmt.dim(`${entries.length} reports from ${resolved}`)}\n`);
20
20
  for (const { file, ...report } of entries) {
21
21
  const meanReward = report.mean_reward ?? report.pass_rate ?? 0;
22
- const isPass = report.status === undefined ? meanReward >= 0.5 : report.status === 'pass';
22
+ const isPass = report.run_runner_status === 'fail'
23
+ ? false : report.status === undefined ? meanReward >= 0.5 : report.status === 'pass';
23
24
  const trials = report.trials || [];
24
25
  const avgDur = trials.reduce((s, t) => s + (t.duration_ms || 0), 0) / (trials.length || 1);
25
26
  const totalTokens = trials.reduce((s, t) => s + (t.input_tokens || 0) + (t.output_tokens || 0) + (t.conversation_input_tokens || 0) + (t.conversation_output_tokens || 0), 0);
@@ -43,6 +44,17 @@ export async function runCliPreview(resultsDir, opts) {
43
44
  for (const [label, value] of metrics) {
44
45
  console.log(` ${fmt.dim(label.padEnd(14))} ${fmt.bold(value)}`);
45
46
  }
47
+ if (report.runner_status !== undefined || report.status !== undefined) {
48
+ console.log(` ${fmt.dim('Runner assertions'.padEnd(20))} ${gateLabel(report.runner_status ?? report.status)}`);
49
+ }
50
+ if (report.run_runner_status !== undefined) {
51
+ console.log(` ${fmt.dim('Run assertions'.padEnd(20))} ${gateLabel(report.run_runner_status)}`);
52
+ }
53
+ if (report.threshold != null) {
54
+ const thresholdStatus = report.threshold_status === 'pass' || report.threshold_status === 'fail' ? report.threshold_status : meanReward >= report.threshold ? 'pass' : 'fail';
55
+ console.log(` ${fmt.dim('Aggregate threshold'.padEnd(20))} ${gateLabel(thresholdStatus)} — ${(meanReward * 100).toFixed(1)}% ${thresholdStatus === 'pass' ? '>=' : '<'} ${(report.threshold * 100).toFixed(1)}%`);
56
+ }
57
+ console.log(` ${fmt.dim('Overall'.padEnd(20))} ${gateLabel(isPass ? 'pass' : 'fail')}`);
46
58
  console.log();
47
59
  // ── Trials
48
60
  for (const trial of trials) {
@@ -85,6 +97,10 @@ export async function runCliPreview(resultsDir, opts) {
85
97
  }
86
98
  console.log();
87
99
  }
100
+ if (trials.some((trial) => trial.scorer_results?.some(scorer => scorer.status === 'skipped'))) {
101
+ console.log(` ${fmt.dim('Skipped scorers keep their declared weight and contribute zero.')}`);
102
+ console.log();
103
+ }
88
104
  // ── LLM scorer details
89
105
  const hasLlm = trials.some((t) => t.scorer_results?.some((g) => g.scorer_type === 'llm_rubric'));
90
106
  if (hasLlm) {
@@ -120,6 +136,9 @@ export async function runCliPreview(resultsDir, opts) {
120
136
  console.log();
121
137
  }
122
138
  }
139
+ function gateLabel(status) {
140
+ return status === 'pass' ? fmt.pass('PASS') : fmt.fail('FAIL');
141
+ }
123
142
  function formatPassAtK(value, reason) {
124
143
  if (typeof value === 'number')
125
144
  return `${(value * 100).toFixed(1)}% (legacy v1)`;
@@ -83,15 +83,22 @@ export function formatReportMarkdown(report, opts) {
83
83
  }
84
84
  if (report.threshold != null) {
85
85
  lines.push('');
86
- lines.push(`Threshold: ${pct(report.threshold)} ${report.status.toUpperCase()}`);
86
+ lines.push(`**Runner assertions:** ${(report.runner_status ?? report.status).toUpperCase()}`);
87
+ lines.push(`**Aggregate threshold:** ${(report.threshold_status ?? (meanReward >= report.threshold ? 'pass' : 'fail')).toUpperCase()} — ${pct(meanReward)} ${meanReward >= report.threshold ? '>=' : '<'} ${pct(report.threshold)}`);
88
+ lines.push(`**Overall:** ${report.status.toUpperCase()}`);
89
+ }
90
+ else {
91
+ lines.push('');
92
+ lines.push(`**Runner assertions:** ${(report.runner_status ?? report.status).toUpperCase()}`);
93
+ lines.push(`**Overall:** ${report.status.toUpperCase()}`);
87
94
  }
88
95
  lines.push('');
89
- lines.push('| Group | Mean reward | Success rate | pass@k | Skills | Avg duration |');
90
- lines.push('|---|---|---|---|---|---|');
96
+ lines.push('| Group | Status | Mean reward | Success rate | pass@k | Skills | Avg duration |');
97
+ lines.push('|---|---|---|---|---|---|---|');
91
98
  for (const group of report.groups) {
92
99
  const skills = group.skills_used.length > 0 ? group.skills_used.join(', ') : '—';
93
100
  const avg = computeAvgDuration(group.trials);
94
- lines.push(`| ${escapeTableCell(group.task)} | ${pct(group.mean_reward ?? group.pass_rate ?? 0)} | ${group.success_rate === undefined ? '—' : pct(group.success_rate)} | ${escapeTableCell(formatPassAtK(group.pass_at_k, group.pass_at_k_unavailable_reason))} | ${escapeTableCell(skills)} | ${durationSeconds(avg)} |`);
101
+ lines.push(`| ${escapeTableCell(group.task)} | ${(group.status ?? (group.pass_rate === 1 ? 'pass' : 'fail')).toUpperCase()} | ${pct(group.mean_reward ?? group.pass_rate ?? 0)} | ${group.success_rate === undefined ? '—' : pct(group.success_rate)} | ${escapeTableCell(formatPassAtK(group.pass_at_k, group.pass_at_k_unavailable_reason))} | ${escapeTableCell(skills)} | ${durationSeconds(avg)} |`);
95
102
  }
96
103
  if (report.selection) {
97
104
  lines.push('');
@@ -320,10 +327,14 @@ function formatGroupDetails(group) {
320
327
  out.push(`reward: **${trial.reward === undefined ? 'n/a' : trial.reward.toFixed(2)}** | duration: ${durationSeconds(trial.duration_ms)} | completion: \`${reason}\``);
321
328
  if (trial.scorer_results.length > 0) {
322
329
  out.push('');
323
- out.push('| Scorer | Score | Weight | Details |');
324
- out.push('|---|---|---|---|');
330
+ out.push('| Scorer | Status | Score | Weight | Details |');
331
+ out.push('|---|---|---|---|---|');
325
332
  for (const s of trial.scorer_results) {
326
- out.push(`| ${escapeTableCell(s.scorer_type)} | ${s.score.toFixed(2)} | ${s.weight.toFixed(2)} | ${escapeTableCell(s.details ?? '')} |`);
333
+ out.push(`| ${escapeTableCell(s.scorer_type)} | ${(s.status ?? 'ok').toUpperCase()} | ${s.score.toFixed(2)} | ${s.weight.toFixed(2)} | ${escapeTableCell(s.details ?? '')} |`);
334
+ }
335
+ if (trial.scorer_results.some(scorer => scorer.status === 'skipped')) {
336
+ out.push('');
337
+ out.push('Skipped scorers keep their declared weight and contribute zero.');
327
338
  }
328
339
  }
329
340
  const warnings = trial.diagnostics?.warnings ?? [];
@@ -3,6 +3,10 @@ export interface LoadedReport extends EvalReport {
3
3
  file: string;
4
4
  timestamp?: string;
5
5
  status?: 'pass' | 'fail';
6
+ runner_status?: 'pass' | 'fail';
7
+ run_runner_status?: 'pass' | 'fail';
8
+ threshold_status?: 'pass' | 'fail' | 'not_configured';
9
+ threshold?: number;
6
10
  }
7
11
  export declare function loadReports(resultsDir: string, opts?: {
8
12
  skipTraces?: boolean;
@@ -34,11 +34,18 @@ export async function loadReports(resultsDir, opts) {
34
34
  if (raw.version !== 1 && raw.version !== 2)
35
35
  continue;
36
36
  for (const group of raw.groups) {
37
+ const runnerStatus = groupRunnerStatus(group);
38
+ const thresholdStatus = groupThresholdStatus(raw, group);
37
39
  const report = {
38
40
  file,
39
41
  timestamp: raw.timestamp,
42
+ threshold: raw.threshold,
40
43
  ...group,
41
- status: groupStatus(raw, group),
44
+ runner_status: runnerStatus,
45
+ ...(raw.runner_status === 'pass' || raw.runner_status === 'fail'
46
+ ? { run_runner_status: raw.runner_status } : {}),
47
+ threshold_status: thresholdStatus,
48
+ status: runnerStatus === 'fail' || thresholdStatus === 'fail' ? 'fail' : 'pass',
42
49
  };
43
50
  if (!opts?.skipTraces && group.trace_file) {
44
51
  await hydrateTraces(report, group.trace_file, resolved);
@@ -50,15 +57,25 @@ export async function loadReports(resultsDir, opts) {
50
57
  }
51
58
  return results;
52
59
  }
53
- function groupStatus(raw, group) {
60
+ function groupRunnerStatus(group) {
61
+ if (group.runner_status === 'pass' || group.runner_status === 'fail')
62
+ return group.runner_status;
63
+ const outcomes = Array.isArray(group.trials)
64
+ ? group.trials.map((trial) => trial.runner_outcome).filter((outcome) => outcome !== undefined)
65
+ : [];
66
+ if (outcomes.length > 0) {
67
+ return outcomes.every((outcome) => outcome === 'passed') ? 'pass' : 'fail';
68
+ }
54
69
  if (group.status === 'pass' || group.status === 'fail')
55
70
  return group.status;
56
71
  const meanReward = group.mean_reward ?? group.pass_rate ?? 0;
57
- if (typeof raw.threshold === 'number')
58
- return meanReward >= raw.threshold ? 'pass' : 'fail';
59
- const outcomes = Array.isArray(group.trials) ? group.trials.map((trial) => trial.runner_outcome) : [];
60
- if (outcomes.length > 0 && outcomes.every((outcome) => (outcome === 'passed' || outcome === 'failed' || outcome === 'not-run'))) {
61
- return outcomes.every((outcome) => outcome === 'passed') ? 'pass' : 'fail';
62
- }
63
- return meanReward >= 0.5 ? 'pass' : 'fail';
72
+ return meanReward === 1 ? 'pass' : 'fail';
73
+ }
74
+ function groupThresholdStatus(raw, group) {
75
+ if (group.threshold_status === 'pass' || group.threshold_status === 'fail'
76
+ || group.threshold_status === 'not_configured')
77
+ return group.threshold_status;
78
+ if (typeof raw.threshold !== 'number')
79
+ return 'not_configured';
80
+ return (group.mean_reward ?? group.pass_rate ?? 0) >= raw.threshold ? 'pass' : 'fail';
64
81
  }
@@ -12,7 +12,7 @@ export function buildComparisonContract(input) {
12
12
  case: caseIdentity,
13
13
  definition: evaluation.evaluationDefinitionKey ?? `position:${index}`,
14
14
  scorer: evaluation.scorerRevision,
15
- runtime: runtimeIdentity(evaluation.agent ?? evaluation.trial?.agent),
15
+ runtime: runtimeIdentity(evaluation),
16
16
  }));
17
17
  });
18
18
  const definitionRevision = input.group.sourceRevision
@@ -83,12 +83,26 @@ export function createScorerRevision(scorers) {
83
83
  return undefined;
84
84
  }
85
85
  }
86
- function runtimeIdentity(agent) {
86
+ function runtimeIdentity(evaluation) {
87
+ const agent = evaluation.agent ?? evaluation.trial?.agent;
88
+ if (agent) {
89
+ return {
90
+ name: agent.name,
91
+ model: agent.resolvedModel,
92
+ transport: agent.transport,
93
+ interaction: agent.interactionMode,
94
+ };
95
+ }
96
+ const flow = evaluation.trial?.flow_trace;
87
97
  return {
88
- name: agent?.name,
89
- model: agent?.resolvedModel,
90
- transport: agent?.transport,
91
- interaction: agent?.interactionMode,
98
+ flow: flow?.completeness.runtimeIdentity === 'complete'
99
+ && flow.participants.every(participant => participant.runtime) ? {
100
+ ...(flow.protocol ? { protocol: flow.protocol } : {}),
101
+ rootParticipantId: flow.rootParticipantId,
102
+ participants: flow.participants
103
+ .map(participant => ({ id: participant.id, runtime: participant.runtime }))
104
+ .toSorted((left, right) => left.id.localeCompare(right.id)),
105
+ } : undefined,
92
106
  };
93
107
  }
94
108
  function scorerDeclaration(scorer) {
@@ -30,7 +30,7 @@ export function buildPathgradeReport(input) {
30
30
  const traceFile = `traces/${slug(group.groupName)}.json`;
31
31
  traces.push({ traceFile, trials: report.trials });
32
32
  const strippedTrials = report.trials.map(trial => {
33
- const { session_log, conversation, ...rest } = trial;
33
+ const { session_log, conversation, flow_trace, ...rest } = trial;
34
34
  return rest;
35
35
  });
36
36
  const { trials: _trials, ...rest } = report;
@@ -52,9 +52,12 @@ export function buildPathgradeReport(input) {
52
52
  .map(attempt => attempt.score)
53
53
  .filter((score) => score !== undefined))));
54
54
  const overallMeanReward = average(scores);
55
- const status = input.threshold != null
56
- ? (overallMeanReward >= input.threshold ? 'pass' : 'fail')
57
- : (reportableGroups.every(group => group.cases.every(testCase => testCase.state === 'passed')) ? 'pass' : 'fail');
55
+ const runnerStatus = (input.runStatus === undefined || input.runStatus === 'completed')
56
+ && reportableGroups.every(group => group.cases.every(testCase => (testCase.state !== 'failed'
57
+ && testCase.attempts.every(attempt => attempt.outcome.kind === 'passed')))) ? 'pass' : 'fail';
58
+ const thresholdStatus = input.threshold == null
59
+ ? 'not_configured'
60
+ : overallMeanReward >= input.threshold ? 'pass' : 'fail';
58
61
  return {
59
62
  report: {
60
63
  version: 2,
@@ -64,7 +67,9 @@ export function buildPathgradeReport(input) {
64
67
  attempts_completed: attemptsCompleted,
65
68
  overall_mean_reward: overallMeanReward,
66
69
  overall_pass_rate: overallMeanReward,
67
- status,
70
+ runner_status: runnerStatus,
71
+ threshold_status: thresholdStatus,
72
+ status: runnerStatus === 'fail' || thresholdStatus === 'fail' ? 'fail' : 'pass',
68
73
  groups: consolidatedGroups,
69
74
  ...(input.selection ? { selection: input.selection } : {}),
70
75
  },
@@ -185,15 +190,20 @@ function buildEvalReport(groupName, cases, run) {
185
190
  .filter((score) => score !== undefined);
186
191
  const meanReward = average(rewards);
187
192
  const metrics = binaryMetrics(cases, run);
193
+ const runnerStatus = cases.every(testCase => (testCase.state !== 'failed'
194
+ && testCase.attempts.every(attempt => attempt.outcome.kind === 'passed'))) ? 'pass' : 'fail';
195
+ const thresholdStatus = run.threshold == null
196
+ ? 'not_configured'
197
+ : meanReward >= run.threshold ? 'pass' : 'fail';
188
198
  const skills = new Set();
189
199
  for (const trial of trials)
190
200
  for (const skill of trial.skills_used ?? [])
191
201
  skills.add(skill);
192
202
  return {
193
203
  task: groupName,
194
- status: run.threshold != null
195
- ? (meanReward >= run.threshold ? 'pass' : 'fail')
196
- : (cases.every(testCase => testCase.state === 'passed') ? 'pass' : 'fail'),
204
+ runner_status: runnerStatus,
205
+ threshold_status: thresholdStatus,
206
+ status: runnerStatus === 'fail' || thresholdStatus === 'fail' ? 'fail' : 'pass',
197
207
  mean_reward: meanReward,
198
208
  ...(metrics.successRate !== undefined ? { success_rate: metrics.successRate } : {}),
199
209
  ...(metrics.passAtK ? {