@alis-build/harness-eval 0.1.2 → 0.1.3
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 +92 -8
- package/dist/adapters/claude-code/index.d.ts +2 -2
- package/dist/adapters/claude-code/index.js +2 -1
- package/dist/adapters/codex/index.d.ts +68 -0
- package/dist/adapters/codex/index.js +3 -0
- package/dist/{claude-code-DZ4Vkgp6.js → claude-code-C_7hxC8z.js} +3 -245
- package/dist/claude-code-C_7hxC8z.js.map +1 -0
- package/dist/cli/bin.js +131 -151
- package/dist/cli/bin.js.map +1 -1
- package/dist/codex-0cHO2te9.js +496 -0
- package/dist/codex-0cHO2te9.js.map +1 -0
- package/dist/config/loader.d.ts +2 -2
- package/dist/config/loader.js +2 -2
- package/dist/{index-V22PrR0p.d.ts → index-DnvP1UBl.d.ts} +2 -2
- package/dist/index.d.ts +132 -6
- package/dist/index.js +6 -5
- package/dist/index.js.map +1 -1
- package/dist/loader-B1WmGGzf.d.ts +107 -0
- package/dist/{loader-DcI0KfRX.js → loader-DnQ6Jt0i.js} +472 -209
- package/dist/loader-DnQ6Jt0i.js.map +1 -0
- package/dist/{projections-BcX7w-f6.js → reporter-Biy-5-9M.js} +1335 -758
- package/dist/reporter-Biy-5-9M.js.map +1 -0
- package/dist/runner/suite.d.ts +1 -1
- package/dist/runner/suite.js +1 -1
- package/dist/{suite-DPJMIEbu.d.ts → suite-BEShV0by.d.ts} +2 -2
- package/dist/{suite-Dlzl-HI0.js → suite-BcP64nlb.js} +16 -2
- package/dist/{suite-Dlzl-HI0.js.map → suite-BcP64nlb.js.map} +1 -1
- package/dist/{types-CD3TwOtZ.d.ts → types-0QkNVyp9.d.ts} +2 -2
- package/dist/types-Bac8_Ixb.js +246 -0
- package/dist/types-Bac8_Ixb.js.map +1 -0
- package/dist/types-Bu8uOZZN.d.ts +77 -0
- package/dist/{types-B9H4IZtA.d.ts → types-C0gBkl0-.d.ts} +3 -2
- package/package.json +6 -2
- package/dist/claude-code-DZ4Vkgp6.js.map +0 -1
- package/dist/loader-C9yQHUPC.d.ts +0 -50
- package/dist/loader-DcI0KfRX.js.map +0 -1
- package/dist/projections-BcX7w-f6.js.map +0 -1
|
@@ -1,506 +1,394 @@
|
|
|
1
|
-
import { i as buildJudgeArgs } from "./claude-code-
|
|
2
|
-
import { n as createLimit } from "./suite-
|
|
1
|
+
import { i as buildJudgeArgs } from "./claude-code-C_7hxC8z.js";
|
|
2
|
+
import { n as createLimit, t as runSuite, u as getAdapter } from "./suite-BcP64nlb.js";
|
|
3
|
+
import { s as buildJudgeArgs$1 } from "./codex-0cHO2te9.js";
|
|
4
|
+
import { i as loadGradingConfig, l as ConfigError, o as loadSuiteDocument, s as DEFAULT_PIPELINE_OUTPUTS, t as loadSuite } from "./loader-DnQ6Jt0i.js";
|
|
3
5
|
import { spawn } from "node:child_process";
|
|
4
|
-
import { readFile } from "node:fs/promises";
|
|
5
|
-
import {
|
|
6
|
+
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
7
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
6
8
|
import { createHash, randomUUID } from "node:crypto";
|
|
9
|
+
import { parse } from "yaml";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
7
11
|
//#region src/types/eval-record.ts
|
|
8
12
|
/** Schema version for {@link EvalRunEnvelope} JSON documents. */
|
|
9
13
|
const EVAL_RUN_SCHEMA_VERSION = "1.0";
|
|
10
14
|
/** Schema version embedded in each {@link TrajectoryView} at export time. */
|
|
11
15
|
const TRAJECTORY_SCHEMA_VERSION = "1.0";
|
|
12
16
|
//#endregion
|
|
13
|
-
//#region src/
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
//#region src/grader/prompt.ts
|
|
18
|
+
/**
|
|
19
|
+
* Build the full grader prompt including eval prompt, transcript, and schema.
|
|
20
|
+
*
|
|
21
|
+
* When `systemInstruction` is set it is prepended as a judge-specific prefix.
|
|
22
|
+
*/
|
|
23
|
+
function buildGraderPrompt(input) {
|
|
24
|
+
const expectationList = input.expectations.map((e, i) => `${i + 1}. ${e}`).join("\n");
|
|
25
|
+
return `${input.systemInstruction ? `${input.systemInstruction.trim()}\n\n` : ""}You are an automated evaluation grader (not the agent under test). Your only job is to score expectations against the transcript below.
|
|
26
|
+
|
|
27
|
+
Your job is to evaluate each expectation against the transcript and final response.
|
|
28
|
+
PASS only when there is clear evidence in the transcript or final response.
|
|
29
|
+
When uncertain, FAIL — burden of proof is on PASS.
|
|
30
|
+
|
|
31
|
+
Also critique the expectations themselves if any are trivially satisfied or miss important outcomes.
|
|
32
|
+
|
|
33
|
+
## Eval prompt
|
|
34
|
+
|
|
35
|
+
${input.prompt}
|
|
36
|
+
|
|
37
|
+
## Execution transcript
|
|
38
|
+
|
|
39
|
+
${input.transcript}
|
|
40
|
+
|
|
41
|
+
## Expectations to grade
|
|
42
|
+
|
|
43
|
+
${expectationList}
|
|
44
|
+
|
|
45
|
+
## Output format
|
|
46
|
+
|
|
47
|
+
Respond with ONLY a single JSON object (no markdown fences, no commentary) matching this schema:
|
|
48
|
+
|
|
49
|
+
{
|
|
50
|
+
"expectations": [
|
|
51
|
+
{ "text": "<original expectation>", "passed": true|false, "evidence": "<quote or description>" }
|
|
52
|
+
],
|
|
53
|
+
"summary": { "passed": <int>, "failed": <int>, "total": <int>, "pass_rate": <0.0-1.0> },
|
|
54
|
+
"eval_feedback": {
|
|
55
|
+
"suggestions": [{ "assertion": "<optional>", "reason": "<string>" }],
|
|
56
|
+
"overall": "<brief assessment>"
|
|
57
|
+
}
|
|
20
58
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
return {
|
|
24
|
-
key,
|
|
25
|
-
value: { intValue: String(value) }
|
|
26
|
-
};
|
|
59
|
+
|
|
60
|
+
Include every expectation in the same order. summary must match the expectations array.`;
|
|
27
61
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
62
|
+
//#endregion
|
|
63
|
+
//#region src/grader/parse.ts
|
|
64
|
+
/**
|
|
65
|
+
* Extract assistant text from Claude stdout.
|
|
66
|
+
*
|
|
67
|
+
* Handles plain text, single JSON result envelopes, stream-json arrays, and
|
|
68
|
+
* assistant message objects — the judge subprocess may emit any of these
|
|
69
|
+
* depending on Claude Code version and flags.
|
|
70
|
+
*/
|
|
71
|
+
function extractClaudeResponseText(stdout) {
|
|
72
|
+
const trimmed = stdout.trim();
|
|
73
|
+
if (!trimmed) return "";
|
|
74
|
+
try {
|
|
75
|
+
const data = JSON.parse(trimmed);
|
|
76
|
+
if (Array.isArray(data)) return extractFromEventArray(data) ?? trimmed;
|
|
77
|
+
if (typeof data === "object" && data !== null) {
|
|
78
|
+
const event = data;
|
|
79
|
+
if (event.type === "result" && typeof event.result === "string") return event.result;
|
|
80
|
+
if (event.type === "assistant" && event.message) {
|
|
81
|
+
const text = textFromAssistantMessage(event.message);
|
|
82
|
+
if (text) return text;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} catch {}
|
|
86
|
+
return trimmed;
|
|
34
87
|
}
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Extract assistant text from Codex judge stdout.
|
|
90
|
+
*
|
|
91
|
+
* Handles plain text and JSONL streams from accidental `--json` usage.
|
|
92
|
+
*/
|
|
93
|
+
function extractCodexResponseText(stdout) {
|
|
94
|
+
const trimmed = stdout.trim();
|
|
95
|
+
if (!trimmed) return "";
|
|
96
|
+
const lines = trimmed.split("\n").filter((line) => line.trim().length > 0);
|
|
97
|
+
if (lines.length > 1) for (let i = lines.length - 1; i >= 0; i--) try {
|
|
98
|
+
const event = JSON.parse(lines[i]);
|
|
99
|
+
if (event.type === "item.completed" && (event.item?.type === "assistant_message" || event.item?.item_type === "assistant_message") && event.item.text) return event.item.text;
|
|
100
|
+
} catch {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
return trimmed;
|
|
104
|
+
}
|
|
105
|
+
/** Walk a stream-json event array and return the final assistant or result text. */
|
|
106
|
+
function extractFromEventArray(events) {
|
|
107
|
+
const result = events.find((e) => typeof e === "object" && e !== null && e.type === "result");
|
|
108
|
+
if (result?.result) return result.result;
|
|
109
|
+
const assistantTexts = [];
|
|
110
|
+
for (const event of events) if (typeof event === "object" && event !== null && event.type === "assistant") {
|
|
111
|
+
const text = textFromAssistantMessage(event.message);
|
|
112
|
+
if (text) assistantTexts.push(text);
|
|
113
|
+
}
|
|
114
|
+
if (assistantTexts.length > 0) return assistantTexts[assistantTexts.length - 1];
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
/** Concatenate text blocks from an Anthropic-style assistant message object. */
|
|
118
|
+
function textFromAssistantMessage(message) {
|
|
119
|
+
if (!message || typeof message !== "object") return null;
|
|
120
|
+
const content = message.content;
|
|
121
|
+
if (typeof content === "string") return content;
|
|
122
|
+
if (!Array.isArray(content)) return null;
|
|
123
|
+
const texts = [];
|
|
124
|
+
for (const block of content) if (typeof block === "object" && block !== null && block.type === "text" && typeof block.text === "string") texts.push(block.text);
|
|
125
|
+
return texts.length > 0 ? texts.join("\n") : null;
|
|
41
126
|
}
|
|
42
|
-
//#endregion
|
|
43
|
-
//#region src/otel/messages.ts
|
|
44
127
|
/**
|
|
45
|
-
*
|
|
128
|
+
* Parse grader JSON from response text.
|
|
46
129
|
*
|
|
47
|
-
*
|
|
130
|
+
* Tries the raw string first, then fenced code blocks and brace-delimited
|
|
131
|
+
* substrings. Returns null when no valid expectations array is found.
|
|
48
132
|
*/
|
|
49
|
-
function
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
133
|
+
function parseGraderJson(text) {
|
|
134
|
+
const candidates = [text.trim(), extractJsonBlock(text)];
|
|
135
|
+
for (const candidate of candidates) {
|
|
136
|
+
if (!candidate) continue;
|
|
137
|
+
try {
|
|
138
|
+
const normalized = normalizeGraderJson(JSON.parse(candidate));
|
|
139
|
+
if (normalized.expectations.length > 0) return normalized;
|
|
140
|
+
} catch {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
57
143
|
}
|
|
144
|
+
return null;
|
|
58
145
|
}
|
|
59
|
-
/**
|
|
60
|
-
function
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
146
|
+
/** Extract JSON from markdown fences or the outermost `{...}` substring. */
|
|
147
|
+
function extractJsonBlock(text) {
|
|
148
|
+
const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
149
|
+
if (fence?.[1]) return fence[1].trim();
|
|
150
|
+
const start = text.indexOf("{");
|
|
151
|
+
const end = text.lastIndexOf("}");
|
|
152
|
+
if (start >= 0 && end > start) return text.slice(start, end + 1);
|
|
153
|
+
return null;
|
|
67
154
|
}
|
|
68
|
-
/**
|
|
69
|
-
function
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
155
|
+
/** Map raw grader JSON to runtime {@link GraderOutput} with computed summary. */
|
|
156
|
+
function normalizeGraderJson(raw) {
|
|
157
|
+
const expectations = (raw.expectations ?? []).map((e) => ({
|
|
158
|
+
text: e.text ?? "",
|
|
159
|
+
passed: Boolean(e.passed),
|
|
160
|
+
evidence: e.evidence ?? ""
|
|
161
|
+
}));
|
|
162
|
+
const passed = expectations.filter((e) => e.passed).length;
|
|
163
|
+
const failed = expectations.length - passed;
|
|
164
|
+
const total = expectations.length;
|
|
165
|
+
const passRate = raw.summary?.pass_rate ?? raw.summary?.passRate ?? (total === 0 ? 0 : passed / total);
|
|
166
|
+
const summary = {
|
|
167
|
+
passed: raw.summary?.passed ?? passed,
|
|
168
|
+
failed: raw.summary?.failed ?? failed,
|
|
169
|
+
total: raw.summary?.total ?? total,
|
|
170
|
+
passRate
|
|
74
171
|
};
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
});
|
|
83
|
-
for (const call of turn.toolCalls) parts.push(toolCallPart(call));
|
|
84
|
-
const finish = mapStopReason(turn.stopReason);
|
|
85
|
-
return {
|
|
86
|
-
role: "assistant",
|
|
87
|
-
parts,
|
|
88
|
-
...finish ? { finish_reason: finish } : {}
|
|
172
|
+
let evalFeedback;
|
|
173
|
+
if (raw.eval_feedback) evalFeedback = {
|
|
174
|
+
suggestions: (raw.eval_feedback.suggestions ?? []).map((s) => ({
|
|
175
|
+
assertion: s.assertion,
|
|
176
|
+
reason: s.reason ?? ""
|
|
177
|
+
})),
|
|
178
|
+
overall: raw.eval_feedback.overall ?? ""
|
|
89
179
|
};
|
|
90
|
-
}
|
|
91
|
-
/** Aggregate tool results from a turn into a single tool-role message, if any. */
|
|
92
|
-
function toolResultsMessage(calls) {
|
|
93
|
-
const parts = calls.filter((c) => c.result !== null).map((c) => toolResponsePart(c));
|
|
94
|
-
if (parts.length === 0) return null;
|
|
95
180
|
return {
|
|
96
|
-
|
|
97
|
-
|
|
181
|
+
expectations,
|
|
182
|
+
summary,
|
|
183
|
+
evalFeedback
|
|
98
184
|
};
|
|
99
185
|
}
|
|
186
|
+
//#endregion
|
|
187
|
+
//#region src/grader/spawn-judge.ts
|
|
100
188
|
/**
|
|
101
|
-
*
|
|
189
|
+
* Shared subprocess utilities for judge graders (Claude + Codex).
|
|
190
|
+
*
|
|
191
|
+
* Owns detached spawn, process-group teardown, and SIGTERM → SIGKILL
|
|
192
|
+
* escalation so both graders share one implementation.
|
|
102
193
|
*/
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const turn = view.turns[i];
|
|
114
|
-
if (!turn) continue;
|
|
115
|
-
messages.push(assistantMessageFromTurn(turn));
|
|
116
|
-
const toolMsg = toolResultsMessage(turn.toolCalls);
|
|
117
|
-
if (toolMsg) messages.push(toolMsg);
|
|
194
|
+
const KILL_GRACE_MS = 5e3;
|
|
195
|
+
/** Kill the detached process group (fallback to single process if group kill fails). */
|
|
196
|
+
function killTree(child, signal) {
|
|
197
|
+
if (child.pid === void 0) return;
|
|
198
|
+
try {
|
|
199
|
+
process.kill(-child.pid, signal);
|
|
200
|
+
} catch {
|
|
201
|
+
try {
|
|
202
|
+
child.kill(signal);
|
|
203
|
+
} catch {}
|
|
118
204
|
}
|
|
119
|
-
return messages;
|
|
120
205
|
}
|
|
121
|
-
//#endregion
|
|
122
|
-
//#region src/otel/types.ts
|
|
123
|
-
/** OTLP span kinds (enum integers). */
|
|
124
|
-
const SpanKind = {
|
|
125
|
-
INTERNAL: 1,
|
|
126
|
-
CLIENT: 2
|
|
127
|
-
};
|
|
128
|
-
/** OTLP status codes. */
|
|
129
|
-
const StatusCode = {
|
|
130
|
-
UNSET: 0,
|
|
131
|
-
OK: 1,
|
|
132
|
-
ERROR: 2
|
|
133
|
-
};
|
|
134
|
-
//#endregion
|
|
135
|
-
//#region src/otel/emitter.ts
|
|
136
|
-
/**
|
|
137
|
-
* TrajectoryView → OTLP JSON export using OpenTelemetry GenAI semantic conventions.
|
|
138
|
-
*
|
|
139
|
-
* Produces an `ExportTraceServiceRequest` suitable for OTLP/HTTP JSON ingestion.
|
|
140
|
-
* Assertions continue to use {@link TrajectoryView} directly; this is export-only.
|
|
141
|
-
*/
|
|
142
|
-
const INSTRUMENTATION_VERSION = "0.1.0";
|
|
143
206
|
/**
|
|
144
|
-
*
|
|
207
|
+
* Spawn a judge subprocess with process-group teardown and collect stdout.
|
|
145
208
|
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
* invoke_agent
|
|
149
|
-
* ├── chat {model}
|
|
150
|
-
* ├── execute_tool {name}
|
|
151
|
-
* ├── chat {model}
|
|
152
|
-
* └── ...
|
|
153
|
-
* ```
|
|
209
|
+
* Non-zero exit with empty stdout is treated as failure; partial stdout on
|
|
210
|
+
* non-zero exit is retained (judges sometimes exit non-zero after emitting JSON).
|
|
154
211
|
*/
|
|
155
|
-
function
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const rootStartNs = msToNs(startMs);
|
|
166
|
-
const rootEndNs = msToNs(endMs);
|
|
167
|
-
const spans = [];
|
|
168
|
-
const timings = buildSpanTimings(view, startMs, endMs);
|
|
169
|
-
spans.push({
|
|
170
|
-
traceId,
|
|
171
|
-
spanId: rootSpanId,
|
|
172
|
-
name: "invoke_agent",
|
|
173
|
-
kind: SpanKind.INTERNAL,
|
|
174
|
-
startTimeUnixNano: rootStartNs,
|
|
175
|
-
endTimeUnixNano: rootEndNs,
|
|
176
|
-
attributes: [
|
|
177
|
-
strAttr("gen_ai.operation.name", "invoke_agent"),
|
|
178
|
-
strAttr("gen_ai.agent.name", agentName),
|
|
179
|
-
strAttr("gen_ai.provider.name", providerName),
|
|
180
|
-
strAttr("gen_ai.conversation.id", view.meta.sessionId),
|
|
181
|
-
strAttr("gen_ai.request.model", view.meta.model),
|
|
182
|
-
strAttr("gen_ai.response.model", view.meta.model),
|
|
183
|
-
intAttr("gen_ai.usage.input_tokens", view.usage.inputTokens),
|
|
184
|
-
intAttr("gen_ai.usage.output_tokens", view.usage.outputTokens),
|
|
185
|
-
boolAttr("harness_eval.success", view.success)
|
|
186
|
-
],
|
|
187
|
-
status: viewStatus(view)
|
|
188
|
-
});
|
|
189
|
-
let opIndex = 0;
|
|
190
|
-
for (const turn of view.turns) {
|
|
191
|
-
const chatTiming = timings[opIndex++];
|
|
192
|
-
const chatSpanId = spanIdFromKey(traceId, `chat:${turn.turnIndex}`);
|
|
193
|
-
const inputMessages = inputMessagesBeforeTurn(view, turn.turnIndex, options.prompt);
|
|
194
|
-
const outputMessages = [assistantMessageFromTurn(turn)];
|
|
195
|
-
spans.push({
|
|
196
|
-
traceId,
|
|
197
|
-
spanId: chatSpanId,
|
|
198
|
-
parentSpanId: rootSpanId,
|
|
199
|
-
name: `chat ${view.meta.model}`,
|
|
200
|
-
kind: SpanKind.CLIENT,
|
|
201
|
-
startTimeUnixNano: chatTiming.startNs,
|
|
202
|
-
endTimeUnixNano: chatTiming.endNs,
|
|
203
|
-
attributes: [
|
|
204
|
-
strAttr("gen_ai.operation.name", "chat"),
|
|
205
|
-
strAttr("gen_ai.provider.name", providerName),
|
|
206
|
-
strAttr("gen_ai.request.model", view.meta.model),
|
|
207
|
-
strAttr("gen_ai.response.model", view.meta.model),
|
|
208
|
-
...inputMessages.length > 0 ? [jsonAttr("gen_ai.input.messages", inputMessages)] : [],
|
|
209
|
-
jsonAttr("gen_ai.output.messages", outputMessages),
|
|
210
|
-
...turn.stopReason ? [jsonAttr("gen_ai.response.finish_reasons", [mapStopReason(turn.stopReason) ?? turn.stopReason])] : []
|
|
212
|
+
function spawnCollectStdout(options) {
|
|
213
|
+
const { binary, args, timeoutMs, env, cwd } = options;
|
|
214
|
+
return new Promise((resolve, reject) => {
|
|
215
|
+
const child = spawn(binary, args, {
|
|
216
|
+
env: env ?? process.env,
|
|
217
|
+
cwd,
|
|
218
|
+
stdio: [
|
|
219
|
+
"ignore",
|
|
220
|
+
"pipe",
|
|
221
|
+
"pipe"
|
|
211
222
|
],
|
|
212
|
-
|
|
223
|
+
detached: true
|
|
213
224
|
});
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
} : { code: StatusCode.OK }
|
|
240
|
-
});
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
return { resourceSpans: [{
|
|
244
|
-
resource: { attributes: [strAttr("service.name", serviceName), strAttr("gen_ai.agent.name", agentName)] },
|
|
245
|
-
scopeSpans: [{
|
|
246
|
-
scope: {
|
|
247
|
-
name: scopeName,
|
|
248
|
-
version: INSTRUMENTATION_VERSION
|
|
249
|
-
},
|
|
250
|
-
spans
|
|
251
|
-
}]
|
|
252
|
-
}] };
|
|
225
|
+
const chunks = [];
|
|
226
|
+
child.stdout?.setEncoding("utf8");
|
|
227
|
+
child.stdout?.on("data", (c) => chunks.push(c));
|
|
228
|
+
const stderrChunks = [];
|
|
229
|
+
child.stderr?.setEncoding("utf8");
|
|
230
|
+
child.stderr?.on("data", (c) => stderrChunks.push(c));
|
|
231
|
+
let killEscalation = null;
|
|
232
|
+
const timer = setTimeout(() => {
|
|
233
|
+
killTree(child, "SIGTERM");
|
|
234
|
+
killEscalation = setTimeout(() => killTree(child, "SIGKILL"), KILL_GRACE_MS);
|
|
235
|
+
const stderrHint = stderrChunks.join("").trim().slice(0, 400);
|
|
236
|
+
reject(/* @__PURE__ */ new Error(`grader timed out after ${timeoutMs}ms` + (stderrHint ? ` (stderr: ${stderrHint})` : "")));
|
|
237
|
+
}, timeoutMs);
|
|
238
|
+
const finalize = (err) => {
|
|
239
|
+
clearTimeout(timer);
|
|
240
|
+
if (killEscalation) clearTimeout(killEscalation);
|
|
241
|
+
if (err) reject(err);
|
|
242
|
+
else resolve(chunks.join(""));
|
|
243
|
+
};
|
|
244
|
+
child.on("error", (err) => finalize(err));
|
|
245
|
+
child.on("close", (code) => {
|
|
246
|
+
if (code !== 0 && chunks.length === 0) finalize(/* @__PURE__ */ new Error(`grader exited ${code}: ${stderrChunks.join("").slice(0, 500)}`));
|
|
247
|
+
else finalize();
|
|
248
|
+
});
|
|
249
|
+
});
|
|
253
250
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
|
|
258
|
-
|
|
251
|
+
//#endregion
|
|
252
|
+
//#region src/grader/claude-grader.ts
|
|
253
|
+
/**
|
|
254
|
+
* Grade expectations by spawning Claude as judge (skill-creator grader pattern).
|
|
255
|
+
*/
|
|
256
|
+
const DEFAULT_TIMEOUT_MS$1 = 3e5;
|
|
257
|
+
/**
|
|
258
|
+
* Judge subprocess defaults — grading is a single-shot JSON response, not an agent session.
|
|
259
|
+
* Without these, Claude Code may load plugins/MCP and loop on tools until timeout.
|
|
260
|
+
*/
|
|
261
|
+
const JUDGE_CLAUDE_DEFAULTS = {
|
|
262
|
+
maxTurns: 1,
|
|
263
|
+
bare: true,
|
|
264
|
+
disableSlashCommands: true,
|
|
265
|
+
noSessionPersistence: true
|
|
266
|
+
};
|
|
267
|
+
/** Merge user-supplied Claude Code options over judge-safe defaults. */
|
|
268
|
+
function mergeJudgeClaudeOptions(claudeCode) {
|
|
259
269
|
return {
|
|
260
|
-
|
|
261
|
-
|
|
270
|
+
...JUDGE_CLAUDE_DEFAULTS,
|
|
271
|
+
...claudeCode
|
|
262
272
|
};
|
|
263
273
|
}
|
|
274
|
+
/** Factory returning a {@link GraderFn} bound to subprocess options. */
|
|
275
|
+
function createClaudeGrader(options = {}) {
|
|
276
|
+
return (input) => runClaudeGrader(input, options);
|
|
277
|
+
}
|
|
264
278
|
/**
|
|
265
|
-
*
|
|
279
|
+
* Spawn Claude as judge, parse JSON response, align with input expectations.
|
|
266
280
|
*
|
|
267
|
-
*
|
|
268
|
-
* duration evenly across chat/tool slots for OTLP consumers that require
|
|
269
|
-
* start/end times on every span.
|
|
281
|
+
* Unparseable output fails all expectations and sets {@link GraderOutput.error}.
|
|
270
282
|
*/
|
|
271
|
-
function
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
283
|
+
async function runClaudeGrader(input, options = {}) {
|
|
284
|
+
const binary = options.binary ?? options.claudeCode?.binary ?? "claude";
|
|
285
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS$1;
|
|
286
|
+
const prompt = buildGraderPrompt(input);
|
|
287
|
+
const model = options.model ?? options.claudeCode?.model;
|
|
288
|
+
const responseText = extractClaudeResponseText(await spawnCollectStdout({
|
|
289
|
+
binary,
|
|
290
|
+
args: buildJudgeArgs(prompt, {
|
|
291
|
+
...mergeJudgeClaudeOptions(options.claudeCode),
|
|
292
|
+
model
|
|
293
|
+
}),
|
|
294
|
+
timeoutMs,
|
|
295
|
+
env: buildChildEnv(options.env),
|
|
296
|
+
cwd: options.cwd
|
|
297
|
+
}));
|
|
298
|
+
const parsed = parseGraderJson(responseText);
|
|
299
|
+
if (!parsed) return {
|
|
300
|
+
expectations: input.expectations.map((text) => ({
|
|
301
|
+
text,
|
|
302
|
+
passed: false,
|
|
303
|
+
evidence: "Grader returned unparseable output"
|
|
304
|
+
})),
|
|
305
|
+
summary: {
|
|
306
|
+
passed: 0,
|
|
307
|
+
failed: input.expectations.length,
|
|
308
|
+
total: input.expectations.length,
|
|
309
|
+
passRate: 0
|
|
310
|
+
},
|
|
311
|
+
error: `failed to parse grader JSON from response: ${responseText.slice(0, 200)}`
|
|
312
|
+
};
|
|
313
|
+
const expectations = input.expectations.map((text, i) => {
|
|
314
|
+
const graded = parsed.expectations[i];
|
|
315
|
+
return {
|
|
316
|
+
text,
|
|
317
|
+
passed: graded?.passed ?? false,
|
|
318
|
+
evidence: graded?.evidence ?? "No evidence returned"
|
|
319
|
+
};
|
|
320
|
+
});
|
|
321
|
+
const passed = expectations.filter((e) => e.passed).length;
|
|
322
|
+
const total = expectations.length;
|
|
323
|
+
return {
|
|
324
|
+
expectations,
|
|
325
|
+
summary: {
|
|
326
|
+
passed,
|
|
327
|
+
failed: total - passed,
|
|
328
|
+
total,
|
|
329
|
+
passRate: total === 0 ? 0 : passed / total
|
|
330
|
+
},
|
|
331
|
+
evalFeedback: parsed.evalFeedback
|
|
332
|
+
};
|
|
291
333
|
}
|
|
292
334
|
/**
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
* Uses SHA-256 truncation so the same session always maps to the same trace.
|
|
335
|
+
* Build subprocess env, stripping CLAUDECODE to avoid nested-session guards.
|
|
296
336
|
*/
|
|
297
|
-
function
|
|
298
|
-
|
|
337
|
+
function buildChildEnv(extraEnv) {
|
|
338
|
+
const env = {
|
|
339
|
+
...process.env,
|
|
340
|
+
...extraEnv
|
|
341
|
+
};
|
|
342
|
+
delete env.CLAUDECODE;
|
|
343
|
+
return env;
|
|
299
344
|
}
|
|
345
|
+
//#endregion
|
|
346
|
+
//#region src/grader/codex-grader.ts
|
|
300
347
|
/**
|
|
301
|
-
*
|
|
302
|
-
*/
|
|
303
|
-
function spanIdFromKey(traceId, key) {
|
|
304
|
-
return createHash("sha256").update(`${traceId}:span:${key}`).digest("hex").slice(0, 16).toUpperCase();
|
|
305
|
-
}
|
|
306
|
-
/** Convert milliseconds since epoch to OTLP nanosecond timestamp string. */
|
|
307
|
-
function msToNs(ms) {
|
|
308
|
-
return String(Math.round(ms * 1e6));
|
|
309
|
-
}
|
|
310
|
-
//#endregion
|
|
311
|
-
//#region src/grader/prompt.ts
|
|
312
|
-
/**
|
|
313
|
-
* Build the full grader prompt including eval prompt, transcript, and schema.
|
|
314
|
-
*
|
|
315
|
-
* When `systemInstruction` is set it is prepended as a judge-specific prefix.
|
|
316
|
-
*/
|
|
317
|
-
function buildGraderPrompt(input) {
|
|
318
|
-
const expectationList = input.expectations.map((e, i) => `${i + 1}. ${e}`).join("\n");
|
|
319
|
-
return `${input.systemInstruction ? `${input.systemInstruction.trim()}\n\n` : ""}You are an automated evaluation grader (not the agent under test). Your only job is to score expectations against the transcript below.
|
|
320
|
-
|
|
321
|
-
Your job is to evaluate each expectation against the transcript and final response.
|
|
322
|
-
PASS only when there is clear evidence in the transcript or final response.
|
|
323
|
-
When uncertain, FAIL — burden of proof is on PASS.
|
|
324
|
-
|
|
325
|
-
Also critique the expectations themselves if any are trivially satisfied or miss important outcomes.
|
|
326
|
-
|
|
327
|
-
## Eval prompt
|
|
328
|
-
|
|
329
|
-
${input.prompt}
|
|
330
|
-
|
|
331
|
-
## Execution transcript
|
|
332
|
-
|
|
333
|
-
${input.transcript}
|
|
334
|
-
|
|
335
|
-
## Expectations to grade
|
|
336
|
-
|
|
337
|
-
${expectationList}
|
|
338
|
-
|
|
339
|
-
## Output format
|
|
340
|
-
|
|
341
|
-
Respond with ONLY a single JSON object (no markdown fences, no commentary) matching this schema:
|
|
342
|
-
|
|
343
|
-
{
|
|
344
|
-
"expectations": [
|
|
345
|
-
{ "text": "<original expectation>", "passed": true|false, "evidence": "<quote or description>" }
|
|
346
|
-
],
|
|
347
|
-
"summary": { "passed": <int>, "failed": <int>, "total": <int>, "pass_rate": <0.0-1.0> },
|
|
348
|
-
"eval_feedback": {
|
|
349
|
-
"suggestions": [{ "assertion": "<optional>", "reason": "<string>" }],
|
|
350
|
-
"overall": "<brief assessment>"
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
Include every expectation in the same order. summary must match the expectations array.`;
|
|
355
|
-
}
|
|
356
|
-
//#endregion
|
|
357
|
-
//#region src/grader/parse.ts
|
|
358
|
-
/**
|
|
359
|
-
* Extract assistant text from Claude stdout.
|
|
360
|
-
*
|
|
361
|
-
* Handles plain text, single JSON result envelopes, stream-json arrays, and
|
|
362
|
-
* assistant message objects — the judge subprocess may emit any of these
|
|
363
|
-
* depending on Claude Code version and flags.
|
|
364
|
-
*/
|
|
365
|
-
function extractClaudeResponseText(stdout) {
|
|
366
|
-
const trimmed = stdout.trim();
|
|
367
|
-
if (!trimmed) return "";
|
|
368
|
-
try {
|
|
369
|
-
const data = JSON.parse(trimmed);
|
|
370
|
-
if (Array.isArray(data)) return extractFromEventArray(data) ?? trimmed;
|
|
371
|
-
if (typeof data === "object" && data !== null) {
|
|
372
|
-
const event = data;
|
|
373
|
-
if (event.type === "result" && typeof event.result === "string") return event.result;
|
|
374
|
-
if (event.type === "assistant" && event.message) {
|
|
375
|
-
const text = textFromAssistantMessage(event.message);
|
|
376
|
-
if (text) return text;
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
} catch {}
|
|
380
|
-
return trimmed;
|
|
381
|
-
}
|
|
382
|
-
/** Walk a stream-json event array and return the final assistant or result text. */
|
|
383
|
-
function extractFromEventArray(events) {
|
|
384
|
-
const result = events.find((e) => typeof e === "object" && e !== null && e.type === "result");
|
|
385
|
-
if (result?.result) return result.result;
|
|
386
|
-
const assistantTexts = [];
|
|
387
|
-
for (const event of events) if (typeof event === "object" && event !== null && event.type === "assistant") {
|
|
388
|
-
const text = textFromAssistantMessage(event.message);
|
|
389
|
-
if (text) assistantTexts.push(text);
|
|
390
|
-
}
|
|
391
|
-
if (assistantTexts.length > 0) return assistantTexts[assistantTexts.length - 1];
|
|
392
|
-
return null;
|
|
393
|
-
}
|
|
394
|
-
/** Concatenate text blocks from an Anthropic-style assistant message object. */
|
|
395
|
-
function textFromAssistantMessage(message) {
|
|
396
|
-
if (!message || typeof message !== "object") return null;
|
|
397
|
-
const content = message.content;
|
|
398
|
-
if (typeof content === "string") return content;
|
|
399
|
-
if (!Array.isArray(content)) return null;
|
|
400
|
-
const texts = [];
|
|
401
|
-
for (const block of content) if (typeof block === "object" && block !== null && block.type === "text" && typeof block.text === "string") texts.push(block.text);
|
|
402
|
-
return texts.length > 0 ? texts.join("\n") : null;
|
|
403
|
-
}
|
|
404
|
-
/**
|
|
405
|
-
* Parse grader JSON from response text.
|
|
406
|
-
*
|
|
407
|
-
* Tries the raw string first, then fenced code blocks and brace-delimited
|
|
408
|
-
* substrings. Returns null when no valid expectations array is found.
|
|
409
|
-
*/
|
|
410
|
-
function parseGraderJson(text) {
|
|
411
|
-
const candidates = [text.trim(), extractJsonBlock(text)];
|
|
412
|
-
for (const candidate of candidates) {
|
|
413
|
-
if (!candidate) continue;
|
|
414
|
-
try {
|
|
415
|
-
const normalized = normalizeGraderJson(JSON.parse(candidate));
|
|
416
|
-
if (normalized.expectations.length > 0) return normalized;
|
|
417
|
-
} catch {
|
|
418
|
-
continue;
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
return null;
|
|
422
|
-
}
|
|
423
|
-
/** Extract JSON from markdown fences or the outermost `{...}` substring. */
|
|
424
|
-
function extractJsonBlock(text) {
|
|
425
|
-
const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
426
|
-
if (fence?.[1]) return fence[1].trim();
|
|
427
|
-
const start = text.indexOf("{");
|
|
428
|
-
const end = text.lastIndexOf("}");
|
|
429
|
-
if (start >= 0 && end > start) return text.slice(start, end + 1);
|
|
430
|
-
return null;
|
|
431
|
-
}
|
|
432
|
-
/** Map raw grader JSON to runtime {@link GraderOutput} with computed summary. */
|
|
433
|
-
function normalizeGraderJson(raw) {
|
|
434
|
-
const expectations = (raw.expectations ?? []).map((e) => ({
|
|
435
|
-
text: e.text ?? "",
|
|
436
|
-
passed: Boolean(e.passed),
|
|
437
|
-
evidence: e.evidence ?? ""
|
|
438
|
-
}));
|
|
439
|
-
const passed = expectations.filter((e) => e.passed).length;
|
|
440
|
-
const failed = expectations.length - passed;
|
|
441
|
-
const total = expectations.length;
|
|
442
|
-
const passRate = raw.summary?.pass_rate ?? raw.summary?.passRate ?? (total === 0 ? 0 : passed / total);
|
|
443
|
-
const summary = {
|
|
444
|
-
passed: raw.summary?.passed ?? passed,
|
|
445
|
-
failed: raw.summary?.failed ?? failed,
|
|
446
|
-
total: raw.summary?.total ?? total,
|
|
447
|
-
passRate
|
|
448
|
-
};
|
|
449
|
-
let evalFeedback;
|
|
450
|
-
if (raw.eval_feedback) evalFeedback = {
|
|
451
|
-
suggestions: (raw.eval_feedback.suggestions ?? []).map((s) => ({
|
|
452
|
-
assertion: s.assertion,
|
|
453
|
-
reason: s.reason ?? ""
|
|
454
|
-
})),
|
|
455
|
-
overall: raw.eval_feedback.overall ?? ""
|
|
456
|
-
};
|
|
457
|
-
return {
|
|
458
|
-
expectations,
|
|
459
|
-
summary,
|
|
460
|
-
evalFeedback
|
|
461
|
-
};
|
|
462
|
-
}
|
|
463
|
-
//#endregion
|
|
464
|
-
//#region src/grader/claude-grader.ts
|
|
465
|
-
/**
|
|
466
|
-
* Grade expectations by spawning Claude as judge (skill-creator grader pattern).
|
|
348
|
+
* Grade expectations by spawning Codex as judge.
|
|
467
349
|
*/
|
|
468
350
|
const DEFAULT_TIMEOUT_MS = 3e5;
|
|
469
|
-
/**
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
maxTurns: 1,
|
|
475
|
-
bare: true,
|
|
476
|
-
disableSlashCommands: true,
|
|
477
|
-
noSessionPersistence: true
|
|
351
|
+
/** Judge subprocess defaults — single-shot grading without persistent sessions. */
|
|
352
|
+
const JUDGE_CODEX_DEFAULTS = {
|
|
353
|
+
ephemeral: true,
|
|
354
|
+
ignoreUserConfig: true,
|
|
355
|
+
skipGitRepoCheck: true
|
|
478
356
|
};
|
|
479
|
-
/** Merge user-supplied
|
|
480
|
-
function
|
|
357
|
+
/** Merge user-supplied Codex options over judge-safe defaults. */
|
|
358
|
+
function mergeJudgeCodexOptions(codex) {
|
|
481
359
|
return {
|
|
482
|
-
...
|
|
483
|
-
...
|
|
360
|
+
...JUDGE_CODEX_DEFAULTS,
|
|
361
|
+
...codex
|
|
484
362
|
};
|
|
485
363
|
}
|
|
486
364
|
/** Factory returning a {@link GraderFn} bound to subprocess options. */
|
|
487
|
-
function
|
|
488
|
-
return (input) =>
|
|
365
|
+
function createCodexGrader(options = {}) {
|
|
366
|
+
return (input) => runCodexGrader(input, options);
|
|
489
367
|
}
|
|
490
368
|
/**
|
|
491
|
-
* Spawn
|
|
369
|
+
* Spawn Codex as judge, parse JSON response, align with input expectations.
|
|
492
370
|
*
|
|
493
371
|
* Unparseable output fails all expectations and sets {@link GraderOutput.error}.
|
|
494
372
|
*/
|
|
495
|
-
async function
|
|
496
|
-
const binary = options.binary ?? options.
|
|
373
|
+
async function runCodexGrader(input, options = {}) {
|
|
374
|
+
const binary = options.binary ?? options.codex?.binary ?? "codex";
|
|
497
375
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
498
376
|
const prompt = buildGraderPrompt(input);
|
|
499
|
-
const model = options.model ?? options.
|
|
500
|
-
const responseText =
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
377
|
+
const model = options.model ?? options.codex?.model;
|
|
378
|
+
const responseText = extractCodexResponseText(await spawnCollectStdout({
|
|
379
|
+
binary,
|
|
380
|
+
args: buildJudgeArgs$1(prompt, {
|
|
381
|
+
...mergeJudgeCodexOptions(options.codex),
|
|
382
|
+
model,
|
|
383
|
+
cwd: options.cwd
|
|
384
|
+
}),
|
|
385
|
+
timeoutMs,
|
|
386
|
+
env: {
|
|
387
|
+
...process.env,
|
|
388
|
+
...options.env
|
|
389
|
+
},
|
|
390
|
+
cwd: options.cwd
|
|
391
|
+
}));
|
|
504
392
|
const parsed = parseGraderJson(responseText);
|
|
505
393
|
if (!parsed) return {
|
|
506
394
|
expectations: input.expectations.map((text) => ({
|
|
@@ -537,57 +425,6 @@ async function runClaudeGrader(input, options = {}) {
|
|
|
537
425
|
evalFeedback: parsed.evalFeedback
|
|
538
426
|
};
|
|
539
427
|
}
|
|
540
|
-
/**
|
|
541
|
-
* Spawn a child process and collect stdout until exit or timeout.
|
|
542
|
-
*
|
|
543
|
-
* Non-zero exit with empty stdout is treated as failure; partial stdout on
|
|
544
|
-
* non-zero exit is retained (Claude sometimes exits non-zero after emitting JSON).
|
|
545
|
-
*/
|
|
546
|
-
function spawnCollectStdout(binary, args, timeoutMs, extraEnv, cwd) {
|
|
547
|
-
return new Promise((resolve, reject) => {
|
|
548
|
-
const child = spawn(binary, args, {
|
|
549
|
-
env: buildChildEnv(extraEnv),
|
|
550
|
-
cwd,
|
|
551
|
-
stdio: [
|
|
552
|
-
"ignore",
|
|
553
|
-
"pipe",
|
|
554
|
-
"pipe"
|
|
555
|
-
]
|
|
556
|
-
});
|
|
557
|
-
const chunks = [];
|
|
558
|
-
child.stdout?.setEncoding("utf8");
|
|
559
|
-
child.stdout?.on("data", (c) => chunks.push(c));
|
|
560
|
-
const stderrChunks = [];
|
|
561
|
-
child.stderr?.setEncoding("utf8");
|
|
562
|
-
child.stderr?.on("data", (c) => stderrChunks.push(c));
|
|
563
|
-
const timer = setTimeout(() => {
|
|
564
|
-
child.kill("SIGTERM");
|
|
565
|
-
const stderrHint = stderrChunks.join("").trim().slice(0, 400);
|
|
566
|
-
reject(/* @__PURE__ */ new Error(`grader timed out after ${timeoutMs}ms` + (stderrHint ? ` (stderr: ${stderrHint})` : "")));
|
|
567
|
-
}, timeoutMs);
|
|
568
|
-
const finalize = (err) => {
|
|
569
|
-
clearTimeout(timer);
|
|
570
|
-
if (err) reject(err);
|
|
571
|
-
else resolve(chunks.join(""));
|
|
572
|
-
};
|
|
573
|
-
child.on("error", (err) => finalize(err));
|
|
574
|
-
child.on("close", (code) => {
|
|
575
|
-
if (code !== 0 && chunks.length === 0) finalize(/* @__PURE__ */ new Error(`grader exited ${code}: ${stderrChunks.join("").slice(0, 500)}`));
|
|
576
|
-
else finalize();
|
|
577
|
-
});
|
|
578
|
-
});
|
|
579
|
-
}
|
|
580
|
-
/**
|
|
581
|
-
* Build subprocess env, stripping CLAUDECODE to avoid nested-session guards.
|
|
582
|
-
*/
|
|
583
|
-
function buildChildEnv(extraEnv) {
|
|
584
|
-
const env = {
|
|
585
|
-
...process.env,
|
|
586
|
-
...extraEnv
|
|
587
|
-
};
|
|
588
|
-
delete env.CLAUDECODE;
|
|
589
|
-
return env;
|
|
590
|
-
}
|
|
591
428
|
//#endregion
|
|
592
429
|
//#region src/grader/expectations.ts
|
|
593
430
|
/**
|
|
@@ -663,6 +500,32 @@ function truncate(text) {
|
|
|
663
500
|
return `${text.slice(0, MAX_RESULT_CHARS)}… (truncated)`;
|
|
664
501
|
}
|
|
665
502
|
//#endregion
|
|
503
|
+
//#region src/eval-record/judge-metadata.ts
|
|
504
|
+
/** Map harness grading adapter id to a stable judge identifier. */
|
|
505
|
+
function judgeIdForAdapter(adapter) {
|
|
506
|
+
switch (adapter) {
|
|
507
|
+
case "codex": return "harness-eval/codex-grader";
|
|
508
|
+
case "claude-code": return "harness-eval/claude-grader";
|
|
509
|
+
default: return adapter ? `harness-eval/${adapter}-grader` : "harness-eval/claude-grader";
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
/** Build {@link JudgeInfo} from grading adapter and optional model override. */
|
|
513
|
+
function resolveJudgeInfo(options) {
|
|
514
|
+
const adapter = options.adapter ?? "claude-code";
|
|
515
|
+
return {
|
|
516
|
+
id: options.id ?? judgeIdForAdapter(adapter),
|
|
517
|
+
model: options.model,
|
|
518
|
+
adapter
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
/** Derive judge metadata from a parsed grading YAML config. */
|
|
522
|
+
function judgeInfoFromGradingConfig(config) {
|
|
523
|
+
return resolveJudgeInfo({
|
|
524
|
+
adapter: config.judge.adapter ?? "claude-code",
|
|
525
|
+
model: config.judge.model ?? config.judge.codex?.model ?? config.judge.claudeCode?.model
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
//#endregion
|
|
666
529
|
//#region src/grader/grade-report.ts
|
|
667
530
|
/**
|
|
668
531
|
* Grade a harness-eval SuiteReport with outcome expectations (LLM judge).
|
|
@@ -675,14 +538,21 @@ function truncate(text) {
|
|
|
675
538
|
*/
|
|
676
539
|
async function gradeReport(report, options = {}) {
|
|
677
540
|
const expectationsMap = options.expectationsPath ? await loadExpectationsMap(options.expectationsPath) : {};
|
|
678
|
-
const gradeFn = options.gradeFn ??
|
|
541
|
+
const gradeFn = options.gradeFn ?? (options.judgeAdapter === "codex" ? createCodexGrader({
|
|
542
|
+
binary: options.binary,
|
|
543
|
+
model: options.model,
|
|
544
|
+
timeoutMs: options.timeoutMs,
|
|
545
|
+
env: options.env,
|
|
546
|
+
cwd: options.cwd,
|
|
547
|
+
codex: options.codex
|
|
548
|
+
}) : createClaudeGrader({
|
|
679
549
|
binary: options.binary,
|
|
680
550
|
model: options.model,
|
|
681
551
|
timeoutMs: options.timeoutMs,
|
|
682
552
|
env: options.env,
|
|
683
553
|
cwd: options.cwd,
|
|
684
554
|
claudeCode: options.claudeCode
|
|
685
|
-
});
|
|
555
|
+
}));
|
|
686
556
|
const limit = createLimit(options.maxConcurrent ?? 2);
|
|
687
557
|
const tasks = [];
|
|
688
558
|
for (const cell of report.cells) {
|
|
@@ -787,6 +657,10 @@ async function gradeReport(report, options = {}) {
|
|
|
787
657
|
gradedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
788
658
|
sourceReport: options.sourceReport ?? "",
|
|
789
659
|
gradingConfigPath: options.gradingConfigPath,
|
|
660
|
+
judge: resolveJudgeInfo({
|
|
661
|
+
adapter: options.judgeAdapter ?? "claude-code",
|
|
662
|
+
model: options.model
|
|
663
|
+
}),
|
|
790
664
|
results,
|
|
791
665
|
summary: {
|
|
792
666
|
passed: passedExpectations,
|
|
@@ -809,11 +683,12 @@ async function loadSuiteReport(path) {
|
|
|
809
683
|
function resolveGradeOptions(fileConfig, cli = {}, configPath) {
|
|
810
684
|
const judge = fileConfig?.judge;
|
|
811
685
|
const adapter = judge?.adapter ?? "claude-code";
|
|
812
|
-
if (adapter !== "claude-code") throw new Error(`unsupported grading adapter "${adapter}" (only claude-code today)`);
|
|
813
686
|
const claudeCode = judge?.claudeCode ?? {};
|
|
814
|
-
const
|
|
815
|
-
const
|
|
816
|
-
|
|
687
|
+
const codex = judge?.codex ?? {};
|
|
688
|
+
const adapterBlock = adapter === "codex" ? codex : claudeCode;
|
|
689
|
+
const binary = cli.binary ?? adapterBlock.binary;
|
|
690
|
+
const model = cli.model ?? judge?.model ?? adapterBlock.model;
|
|
691
|
+
if (adapter === "codex") return {
|
|
817
692
|
sourceReport: cli.sourceReport,
|
|
818
693
|
expectationsPath: cli.expectationsPath,
|
|
819
694
|
model,
|
|
@@ -823,17 +698,37 @@ function resolveGradeOptions(fileConfig, cli = {}, configPath) {
|
|
|
823
698
|
systemInstruction: judge?.system_instruction,
|
|
824
699
|
env: judge?.env,
|
|
825
700
|
cwd: judge?.cwd,
|
|
826
|
-
|
|
827
|
-
|
|
701
|
+
judgeAdapter: "codex",
|
|
702
|
+
codex: {
|
|
703
|
+
...codex,
|
|
828
704
|
binary: void 0,
|
|
829
705
|
model: void 0
|
|
830
706
|
},
|
|
831
707
|
gradingConfigPath: configPath
|
|
832
708
|
};
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
709
|
+
if (adapter !== "claude-code") throw new Error(`unsupported grading adapter "${adapter}" (supported: claude-code, codex)`);
|
|
710
|
+
return {
|
|
711
|
+
sourceReport: cli.sourceReport,
|
|
712
|
+
expectationsPath: cli.expectationsPath,
|
|
713
|
+
model,
|
|
714
|
+
binary,
|
|
715
|
+
timeoutMs: cli.timeoutMs ?? judge?.timeoutMs,
|
|
716
|
+
maxConcurrent: cli.maxConcurrent ?? judge?.maxConcurrent,
|
|
717
|
+
systemInstruction: judge?.system_instruction,
|
|
718
|
+
env: judge?.env,
|
|
719
|
+
cwd: judge?.cwd,
|
|
720
|
+
judgeAdapter: "claude-code",
|
|
721
|
+
claudeCode: {
|
|
722
|
+
...claudeCode,
|
|
723
|
+
binary: void 0,
|
|
724
|
+
model: void 0
|
|
725
|
+
},
|
|
726
|
+
gradingConfigPath: configPath
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
//#endregion
|
|
730
|
+
//#region src/grader/format-console.ts
|
|
731
|
+
const RESET$1 = "\x1B[0m";
|
|
837
732
|
const GREEN$1 = "\x1B[32m";
|
|
838
733
|
const RED$1 = "\x1B[31m";
|
|
839
734
|
const DIM = "\x1B[2m";
|
|
@@ -870,168 +765,6 @@ function gradingReportPassed(report) {
|
|
|
870
765
|
return report.results.every((r) => !r.graderError && r.summary.failed === 0 && r.summary.total > 0);
|
|
871
766
|
}
|
|
872
767
|
//#endregion
|
|
873
|
-
//#region src/reporter/format-console.ts
|
|
874
|
-
const RESET = "\x1B[0m";
|
|
875
|
-
const GREEN = "\x1B[32m";
|
|
876
|
-
const RED = "\x1B[31m";
|
|
877
|
-
const YELLOW = "\x1B[33m";
|
|
878
|
-
/**
|
|
879
|
-
* Render renderable rows as ANSI-colored console output.
|
|
880
|
-
*
|
|
881
|
-
* @param color When false, emit plain text without escape codes.
|
|
882
|
-
*/
|
|
883
|
-
function formatConsole(rows, color = true) {
|
|
884
|
-
const lines = [];
|
|
885
|
-
for (const row of rows) {
|
|
886
|
-
const status = row.passed ? color ? `${GREEN}PASS${RESET}` : "PASS" : color ? `${RED}FAIL${RESET}` : "FAIL";
|
|
887
|
-
const crashNote = row.adapterErrors > 0 ? ` ${color ? YELLOW : ""}[${row.adapterErrors} adapter errors]${color ? RESET : ""}` : "";
|
|
888
|
-
lines.push(`${row.caseId} @ ${row.cellLabel} ${status}${crashNote}`);
|
|
889
|
-
if (row.category) lines.push(` category: ${row.category}`);
|
|
890
|
-
for (const stat of row.stats) {
|
|
891
|
-
const marker = stat.meetsThreshold ? color ? `${GREEN}✓${RESET}` : "✓" : color ? `${RED}✗${RESET}` : "✗";
|
|
892
|
-
const rateStr = formatRate$1(stat);
|
|
893
|
-
const thresholdPct = (stat.threshold * 100).toFixed(0);
|
|
894
|
-
let line = ` ├─ ${stat.description}: ${rateStr} [threshold ${thresholdPct}%] ${marker}`;
|
|
895
|
-
if (stat.delta !== void 0 && stat.baselinePassRate !== void 0) {
|
|
896
|
-
const arrow = stat.delta >= 0 ? "↑" : "↓";
|
|
897
|
-
const basePct = (stat.baselinePassRate * 100).toFixed(0);
|
|
898
|
-
const curPct = (stat.passRate * 100).toFixed(0);
|
|
899
|
-
const deltaPct = (stat.delta * 100).toFixed(0);
|
|
900
|
-
line += ` (${basePct}% → ${curPct}% (${arrow}${deltaPct}%))`;
|
|
901
|
-
}
|
|
902
|
-
lines.push(line);
|
|
903
|
-
}
|
|
904
|
-
lines.push("");
|
|
905
|
-
}
|
|
906
|
-
return lines.join("\n").trimEnd();
|
|
907
|
-
}
|
|
908
|
-
/** Format pass rate for display, noting when all reps crashed. */
|
|
909
|
-
function formatRate$1(stat) {
|
|
910
|
-
if (stat.evaluatedCount === 0) return `0/${stat.totalReps} (all reps crashed)`;
|
|
911
|
-
const pct = (stat.passRate * 100).toFixed(0);
|
|
912
|
-
return `${stat.passedCount}/${stat.evaluatedCount} (${pct}%)`;
|
|
913
|
-
}
|
|
914
|
-
//#endregion
|
|
915
|
-
//#region src/reporter/format-json.ts
|
|
916
|
-
/**
|
|
917
|
-
* Serialize a suite report as indented JSON (no transformation).
|
|
918
|
-
*
|
|
919
|
-
* Used by `--format json` and `--output` persistence.
|
|
920
|
-
*/
|
|
921
|
-
function formatJson(report) {
|
|
922
|
-
return JSON.stringify(report, null, 2);
|
|
923
|
-
}
|
|
924
|
-
//#endregion
|
|
925
|
-
//#region src/reporter/format-markdown.ts
|
|
926
|
-
/** Render renderable rows as a GitHub-flavored markdown report. */
|
|
927
|
-
function formatMarkdown(rows) {
|
|
928
|
-
const lines = ["# Harness Eval Report", ""];
|
|
929
|
-
for (const row of rows) {
|
|
930
|
-
const status = row.passed ? "PASS" : "FAIL";
|
|
931
|
-
const crashNote = row.adapterErrors > 0 ? ` (${row.adapterErrors} adapter errors)` : "";
|
|
932
|
-
lines.push(`## ${row.caseId} @ ${row.cellLabel} — ${status}${crashNote}`);
|
|
933
|
-
if (row.category) lines.push(`**Category:** ${row.category}`);
|
|
934
|
-
if (row.notes) lines.push("<details><summary>Notes</summary>", row.notes, "</details>");
|
|
935
|
-
lines.push("");
|
|
936
|
-
lines.push("| Assertion | Result | Threshold | Status |");
|
|
937
|
-
lines.push("| --- | --- | --- | --- |");
|
|
938
|
-
for (const stat of row.stats) {
|
|
939
|
-
const rateStr = formatRate(stat);
|
|
940
|
-
const threshold = `${(stat.threshold * 100).toFixed(0)}%`;
|
|
941
|
-
const statusCell = stat.meetsThreshold ? "✓" : "✗";
|
|
942
|
-
let result = rateStr;
|
|
943
|
-
if (stat.delta !== void 0 && stat.baselinePassRate !== void 0) {
|
|
944
|
-
const base = (stat.baselinePassRate * 100).toFixed(0);
|
|
945
|
-
const cur = (stat.passRate * 100).toFixed(0);
|
|
946
|
-
const d = (stat.delta * 100).toFixed(0);
|
|
947
|
-
const sign = stat.delta >= 0 ? "+" : "";
|
|
948
|
-
result += ` (${base}% → ${cur}%, ${sign}${d}%)`;
|
|
949
|
-
}
|
|
950
|
-
lines.push(`| ${stat.description} | ${result} | ${threshold} | ${statusCell} |`);
|
|
951
|
-
}
|
|
952
|
-
lines.push("");
|
|
953
|
-
}
|
|
954
|
-
return lines.join("\n").trimEnd();
|
|
955
|
-
}
|
|
956
|
-
/** Format pass rate for markdown tables, noting when all reps crashed. */
|
|
957
|
-
function formatRate(stat) {
|
|
958
|
-
if (stat.evaluatedCount === 0) return `0/${stat.totalReps} (all reps crashed)`;
|
|
959
|
-
const pct = (stat.passRate * 100).toFixed(0);
|
|
960
|
-
return `${stat.passedCount}/${stat.evaluatedCount} (${pct}%)`;
|
|
961
|
-
}
|
|
962
|
-
//#endregion
|
|
963
|
-
//#region src/reporter/renderable.ts
|
|
964
|
-
/** Map a suite report to formatter-ready rows (one per cell). */
|
|
965
|
-
function toRenderableRows(report) {
|
|
966
|
-
return report.cells.map((cell) => cellToRow(cell));
|
|
967
|
-
}
|
|
968
|
-
/**
|
|
969
|
-
* Attach baseline pass-rate deltas to matching rows.
|
|
970
|
-
*
|
|
971
|
-
* Rows without a matching baseline cell are returned unchanged.
|
|
972
|
-
*/
|
|
973
|
-
function applyBaseline(rows, baseline) {
|
|
974
|
-
const baselineMap = new Map(baseline.cells.map((c) => [`${c.caseId}::${c.cell.label}`, c]));
|
|
975
|
-
return rows.map((row) => {
|
|
976
|
-
const baseCell = baselineMap.get(`${row.caseId}::${row.cellLabel}`);
|
|
977
|
-
if (!baseCell) return row;
|
|
978
|
-
const stats = row.stats.map((stat, i) => {
|
|
979
|
-
const baseStat = baseCell.assertionStats[i];
|
|
980
|
-
if (!baseStat) return stat;
|
|
981
|
-
const delta = stat.passRate - baseStat.passRate;
|
|
982
|
-
return {
|
|
983
|
-
...stat,
|
|
984
|
-
baselinePassRate: baseStat.passRate,
|
|
985
|
-
delta
|
|
986
|
-
};
|
|
987
|
-
});
|
|
988
|
-
return {
|
|
989
|
-
...row,
|
|
990
|
-
stats
|
|
991
|
-
};
|
|
992
|
-
});
|
|
993
|
-
}
|
|
994
|
-
/** Convert one {@link CellReport} to a {@link RenderableRow}. */
|
|
995
|
-
function cellToRow(cell) {
|
|
996
|
-
const totalReps = cell.repetitions.length;
|
|
997
|
-
const stats = cell.assertionStats.map((s) => ({
|
|
998
|
-
description: s.description,
|
|
999
|
-
threshold: s.threshold,
|
|
1000
|
-
passedCount: s.passedCount,
|
|
1001
|
-
evaluatedCount: s.evaluatedCount,
|
|
1002
|
-
totalReps,
|
|
1003
|
-
adapterErrors: cell.adapterErrors,
|
|
1004
|
-
passRate: s.passRate,
|
|
1005
|
-
meetsThreshold: s.meetsThreshold
|
|
1006
|
-
}));
|
|
1007
|
-
return {
|
|
1008
|
-
caseId: cell.caseId,
|
|
1009
|
-
category: cell.category,
|
|
1010
|
-
notes: cell.notes,
|
|
1011
|
-
cellLabel: cell.cell.label,
|
|
1012
|
-
passed: cell.passed,
|
|
1013
|
-
adapterErrors: cell.adapterErrors,
|
|
1014
|
-
totalReps,
|
|
1015
|
-
stats
|
|
1016
|
-
};
|
|
1017
|
-
}
|
|
1018
|
-
//#endregion
|
|
1019
|
-
//#region src/reporter/index.ts
|
|
1020
|
-
/**
|
|
1021
|
-
* Format a {@link SuiteReport} for console, markdown, or JSON output.
|
|
1022
|
-
*
|
|
1023
|
-
* JSON format bypasses the renderable intermediate model and serializes the
|
|
1024
|
-
* report directly. Console and markdown apply optional baseline deltas.
|
|
1025
|
-
*/
|
|
1026
|
-
function formatReport(report, options) {
|
|
1027
|
-
if (options.format === "json") return formatJson(report);
|
|
1028
|
-
let rows = toRenderableRows(report);
|
|
1029
|
-
if (options.baseline) rows = applyBaseline(rows, options.baseline);
|
|
1030
|
-
const useColor = options.color ?? options.format === "console";
|
|
1031
|
-
if (options.format === "markdown") return formatMarkdown(rows);
|
|
1032
|
-
return formatConsole(rows, useColor);
|
|
1033
|
-
}
|
|
1034
|
-
//#endregion
|
|
1035
768
|
//#region src/eval-interchange/normalize.ts
|
|
1036
769
|
/**
|
|
1037
770
|
* Serialize tool arguments to the Vertex wire string format.
|
|
@@ -1431,6 +1164,36 @@ function outcomePassForCell(_caseId, _cellLabel, repetitions) {
|
|
|
1431
1164
|
if (graded.length === 0) return void 0;
|
|
1432
1165
|
return graded.every((r) => r.outcomeGrades.error === void 0 && r.outcomeGrades.summary.failed === 0);
|
|
1433
1166
|
}
|
|
1167
|
+
/** Resolve judge metadata for envelope export (explicit options win). */
|
|
1168
|
+
async function resolveEnvelopeJudge(options) {
|
|
1169
|
+
if (options.grading?.judge) return options.grading.judge;
|
|
1170
|
+
if (options.gradingConfigPath) try {
|
|
1171
|
+
return judgeInfoFromGradingConfig(await loadGradingConfig(resolve(options.gradingConfigPath)));
|
|
1172
|
+
} catch {}
|
|
1173
|
+
return resolveJudgeInfo({ adapter: "claude-code" });
|
|
1174
|
+
}
|
|
1175
|
+
/** Path to pass to {@link loadSuite} (directory layout uses the suite folder). */
|
|
1176
|
+
async function resolveSuiteLoadPath(suitePath) {
|
|
1177
|
+
const abs = resolve(suitePath);
|
|
1178
|
+
if (basename(abs) === "suite.yaml") return dirname(abs);
|
|
1179
|
+
try {
|
|
1180
|
+
if ((await stat(abs)).isDirectory()) return abs;
|
|
1181
|
+
} catch {}
|
|
1182
|
+
return abs;
|
|
1183
|
+
}
|
|
1184
|
+
/** Read suite YAML bytes for content hashing. */
|
|
1185
|
+
async function readSuiteYamlContent(suitePath) {
|
|
1186
|
+
const loadPath = await resolveSuiteLoadPath(suitePath);
|
|
1187
|
+
return readFile(basename(resolve(suitePath)) === "suite.yaml" ? resolve(suitePath) : join(loadPath, "suite.yaml"), "utf8");
|
|
1188
|
+
}
|
|
1189
|
+
async function resolveEnvelopeHarnessAdapter(options) {
|
|
1190
|
+
if (options.harnessAdapter) return options.harnessAdapter;
|
|
1191
|
+
if (options.suitePath) try {
|
|
1192
|
+
const suite = await loadSuite(await resolveSuiteLoadPath(options.suitePath));
|
|
1193
|
+
if (suite.adapter) return suite.adapter;
|
|
1194
|
+
} catch {}
|
|
1195
|
+
return "claude-code";
|
|
1196
|
+
}
|
|
1434
1197
|
/**
|
|
1435
1198
|
* Convert a {@link SuiteReport} (and optional grading) into a versioned
|
|
1436
1199
|
* {@link EvalRunEnvelope} for storage or API handoff.
|
|
@@ -1442,7 +1205,7 @@ function outcomePassForCell(_caseId, _cellLabel, repetitions) {
|
|
|
1442
1205
|
function buildEvalRunEnvelope(report, options = {}) {
|
|
1443
1206
|
const includeTranscript = options.includeTranscript !== false;
|
|
1444
1207
|
const includeRaw = options.includeRawStreamEvents === true;
|
|
1445
|
-
const judge = options.grading?.judge ?? {
|
|
1208
|
+
const judge = options.grading?.judge ?? resolveJudgeInfo({ adapter: "claude-code" });
|
|
1446
1209
|
const cells = report.cells.map((cell) => {
|
|
1447
1210
|
const prompt = cell.prompt ?? "";
|
|
1448
1211
|
const referenceTrajectoryConfig = cell.reference_trajectory;
|
|
@@ -1529,111 +1292,925 @@ function buildEvalRunEnvelope(report, options = {}) {
|
|
|
1529
1292
|
};
|
|
1530
1293
|
}
|
|
1531
1294
|
/**
|
|
1532
|
-
* Build an envelope from on-disk runner and grader JSON artifacts.
|
|
1533
|
-
*
|
|
1534
|
-
* Reads `reportPath` as a {@link SuiteReport}. When `gradingPath` is set, merges
|
|
1535
|
-
* outcome grades from a {@link SuiteGradingReport}. When `suitePath` is set,
|
|
1536
|
-
* attaches suite URI and SHA-256 content hash for reproducibility.
|
|
1295
|
+
* Build an envelope from on-disk runner and grader JSON artifacts.
|
|
1296
|
+
*
|
|
1297
|
+
* Reads `reportPath` as a {@link SuiteReport}. When `gradingPath` is set, merges
|
|
1298
|
+
* outcome grades from a {@link SuiteGradingReport}. When `suitePath` is set,
|
|
1299
|
+
* attaches suite URI and SHA-256 content hash for reproducibility.
|
|
1300
|
+
*
|
|
1301
|
+
* @param reportPath - Path to the suite run report JSON from `harness-eval run`.
|
|
1302
|
+
* @param options - Same build options as {@link buildEvalRunEnvelope}, plus file paths.
|
|
1303
|
+
*/
|
|
1304
|
+
async function buildEvalRunEnvelopeFromFiles(reportPath, options = {}) {
|
|
1305
|
+
const reportText = await readFile(reportPath, "utf8");
|
|
1306
|
+
const report = JSON.parse(reportText);
|
|
1307
|
+
const harnessAdapter = await resolveEnvelopeHarnessAdapter({
|
|
1308
|
+
harnessAdapter: options.harness?.adapter,
|
|
1309
|
+
suitePath: options.suitePath
|
|
1310
|
+
});
|
|
1311
|
+
let grading = options.grading;
|
|
1312
|
+
if (options.gradingPath) {
|
|
1313
|
+
const gradingText = await readFile(options.gradingPath, "utf8");
|
|
1314
|
+
const parsed = JSON.parse(gradingText);
|
|
1315
|
+
const judge = parsed.judge ?? await resolveEnvelopeJudge({ gradingConfigPath: parsed.gradingConfigPath });
|
|
1316
|
+
grading = {
|
|
1317
|
+
gradedAt: parsed.gradedAt,
|
|
1318
|
+
sourceReport: parsed.sourceReport,
|
|
1319
|
+
results: parsed.results,
|
|
1320
|
+
judge
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
let suite = options.suite;
|
|
1324
|
+
if (options.suitePath) {
|
|
1325
|
+
const content = await readSuiteYamlContent(options.suitePath);
|
|
1326
|
+
suite = {
|
|
1327
|
+
...suite,
|
|
1328
|
+
uri: options.suitePath,
|
|
1329
|
+
contentHash: createHash("sha256").update(content).digest("hex")
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
return buildEvalRunEnvelope(report, {
|
|
1333
|
+
...options,
|
|
1334
|
+
suite,
|
|
1335
|
+
grading,
|
|
1336
|
+
harness: {
|
|
1337
|
+
...options.harness,
|
|
1338
|
+
adapter: harnessAdapter
|
|
1339
|
+
}
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
//#endregion
|
|
1343
|
+
//#region src/eval-interchange/projections.ts
|
|
1344
|
+
/** Trajectory instance keys emitted in stable order for JSONL export. */
|
|
1345
|
+
const TRAJECTORY_INSTANCE_KEYS = [
|
|
1346
|
+
"exactMatch",
|
|
1347
|
+
"inOrderMatch",
|
|
1348
|
+
"anyOrderMatch",
|
|
1349
|
+
"precision",
|
|
1350
|
+
"recall",
|
|
1351
|
+
"singleToolUse"
|
|
1352
|
+
];
|
|
1353
|
+
/**
|
|
1354
|
+
* Flatten one repetition into a trajectory dataset row.
|
|
1355
|
+
*
|
|
1356
|
+
* Pulls prompt from the cell, response from evaluationInstance, and falls
|
|
1357
|
+
* back to duration-based latency when enrich did not set latencySeconds.
|
|
1358
|
+
*/
|
|
1359
|
+
function repetitionToDatasetRow(cell, repetition) {
|
|
1360
|
+
return {
|
|
1361
|
+
caseId: cell.caseId,
|
|
1362
|
+
repetitionIndex: repetition.repetitionIndex,
|
|
1363
|
+
prompt: cell.prompt,
|
|
1364
|
+
response: repetition.evaluationInstance?.response?.text,
|
|
1365
|
+
evaluationInstance: repetition.evaluationInstance,
|
|
1366
|
+
latencySeconds: repetition.latencySeconds ?? repetition.durationMs / 1e3,
|
|
1367
|
+
failure: repetition.failure ?? (repetition.trajectory?.success ? 0 : 1),
|
|
1368
|
+
humanRatings: cell.humanRatings
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
/**
|
|
1372
|
+
* Expand one repetition into type-tagged instance rows for EvaluateInstances.
|
|
1373
|
+
*
|
|
1374
|
+
* Returns an empty array when the repetition has no reference trajectory
|
|
1375
|
+
* (and therefore no trajectoryInstances block).
|
|
1376
|
+
*/
|
|
1377
|
+
function repetitionToInstanceRows(cell, repetition) {
|
|
1378
|
+
if (!repetition.trajectoryInstances) return [];
|
|
1379
|
+
const rows = [];
|
|
1380
|
+
for (const key of TRAJECTORY_INSTANCE_KEYS) {
|
|
1381
|
+
const instance = repetition.trajectoryInstances[key];
|
|
1382
|
+
if (!instance) continue;
|
|
1383
|
+
rows.push({
|
|
1384
|
+
messageType: trajectoryInstanceMessageType(key),
|
|
1385
|
+
caseId: cell.caseId,
|
|
1386
|
+
repetitionIndex: repetition.repetitionIndex,
|
|
1387
|
+
instance
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
return rows;
|
|
1391
|
+
}
|
|
1392
|
+
/**
|
|
1393
|
+
* Trajectory projection — all repetitions in the envelope as dataset rows.
|
|
1394
|
+
*/
|
|
1395
|
+
function toTrajectory(envelope) {
|
|
1396
|
+
const rows = [];
|
|
1397
|
+
for (const cell of envelope.cells) for (const repetition of cell.repetitions) rows.push(repetitionToDatasetRow(cell, repetition));
|
|
1398
|
+
return rows;
|
|
1399
|
+
}
|
|
1400
|
+
/**
|
|
1401
|
+
* Instances projection — all trajectory metric instances as JSONL rows.
|
|
1402
|
+
*/
|
|
1403
|
+
function toInstancesJsonl(envelope) {
|
|
1404
|
+
const rows = [];
|
|
1405
|
+
for (const cell of envelope.cells) for (const repetition of cell.repetitions) rows.push(...repetitionToInstanceRows(cell, repetition));
|
|
1406
|
+
return rows;
|
|
1407
|
+
}
|
|
1408
|
+
//#endregion
|
|
1409
|
+
//#region src/pipeline/resolve-inputs.ts
|
|
1410
|
+
/**
|
|
1411
|
+
* Resolve pipeline step inputs and outputs with precedence rules.
|
|
1412
|
+
*
|
|
1413
|
+
* Precedence: CLI override > explicit YAML > prior step in this run > default path on disk > error.
|
|
1414
|
+
*/
|
|
1415
|
+
/** Resolve absolute paths for enabled pipeline steps. */
|
|
1416
|
+
async function resolvePipelineInputs(options) {
|
|
1417
|
+
const { suitePath, suiteDir, pipeline, steps, overrides } = options;
|
|
1418
|
+
const executed = options.executed ?? {};
|
|
1419
|
+
const stepSet = new Set(steps);
|
|
1420
|
+
const resolved = { suitePath: resolve(suitePath) };
|
|
1421
|
+
const defaultRunOutput = resolve(suiteDir, pipeline.run?.output ?? DEFAULT_PIPELINE_OUTPUTS.run);
|
|
1422
|
+
const defaultGradeOutput = resolve(suiteDir, pipeline.grade?.output ?? DEFAULT_PIPELINE_OUTPUTS.grade);
|
|
1423
|
+
if (stepSet.has("run") && pipeline.run) resolved.run = {
|
|
1424
|
+
output: resolve(suiteDir, overrides?.run?.output ?? pipeline.run.output),
|
|
1425
|
+
maxConcurrent: overrides?.run?.maxConcurrent ?? pipeline.run.maxConcurrent
|
|
1426
|
+
};
|
|
1427
|
+
if (stepSet.has("grade") && pipeline.grade) resolved.grade = {
|
|
1428
|
+
input: await resolveReportPath({
|
|
1429
|
+
explicit: overrides?.grade?.input ?? pipeline.grade.input,
|
|
1430
|
+
executedOutput: executed.run?.output,
|
|
1431
|
+
defaultPath: defaultRunOutput,
|
|
1432
|
+
label: "grade input (report)"
|
|
1433
|
+
}),
|
|
1434
|
+
output: resolve(suiteDir, overrides?.grade?.output ?? pipeline.grade.output),
|
|
1435
|
+
maxConcurrent: overrides?.grade?.maxConcurrent ?? pipeline.grade.maxConcurrent
|
|
1436
|
+
};
|
|
1437
|
+
if (stepSet.has("envelope") && pipeline.envelope) resolved.envelope = {
|
|
1438
|
+
report: await resolveReportPath({
|
|
1439
|
+
explicit: overrides?.envelope?.report ?? pipeline.envelope.report,
|
|
1440
|
+
executedOutput: executed.run?.output,
|
|
1441
|
+
defaultPath: defaultRunOutput,
|
|
1442
|
+
label: "envelope report"
|
|
1443
|
+
}),
|
|
1444
|
+
grading: await resolveOptionalGradingPath({
|
|
1445
|
+
explicit: overrides?.envelope?.grading ?? pipeline.envelope.grading,
|
|
1446
|
+
executedOutput: executed.grade?.output,
|
|
1447
|
+
defaultPath: defaultGradeOutput
|
|
1448
|
+
}),
|
|
1449
|
+
output: resolve(suiteDir, overrides?.envelope?.output ?? pipeline.envelope.output),
|
|
1450
|
+
projection: overrides?.envelope?.projection ?? pipeline.envelope.projection ?? "envelope",
|
|
1451
|
+
includeRawStreamEvents: pipeline.envelope.includeRawStreamEvents ?? false,
|
|
1452
|
+
noTranscript: pipeline.envelope.noTranscript ?? false
|
|
1453
|
+
};
|
|
1454
|
+
return resolved;
|
|
1455
|
+
}
|
|
1456
|
+
/**
|
|
1457
|
+
* Resolve a required report path: explicit override → prior step output → default on disk.
|
|
1458
|
+
* Throws when none of the above exist.
|
|
1459
|
+
*/
|
|
1460
|
+
async function resolveReportPath(options) {
|
|
1461
|
+
if (options.explicit) return resolve(options.explicit);
|
|
1462
|
+
if (options.executedOutput) return resolve(options.executedOutput);
|
|
1463
|
+
if (await pathExists(options.defaultPath)) return options.defaultPath;
|
|
1464
|
+
throw new ConfigError(`pipeline: could not resolve ${options.label}; specify an explicit path or run the run step first`, options.defaultPath);
|
|
1465
|
+
}
|
|
1466
|
+
/** Resolve optional grading path; returns undefined when grading was not run and file is absent. */
|
|
1467
|
+
async function resolveOptionalGradingPath(options) {
|
|
1468
|
+
if (options.explicit) return resolve(options.explicit);
|
|
1469
|
+
if (options.executedOutput) return resolve(options.executedOutput);
|
|
1470
|
+
if (await pathExists(options.defaultPath)) return options.defaultPath;
|
|
1471
|
+
}
|
|
1472
|
+
async function pathExists(filePath) {
|
|
1473
|
+
try {
|
|
1474
|
+
await stat(filePath);
|
|
1475
|
+
return true;
|
|
1476
|
+
} catch {
|
|
1477
|
+
return false;
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
/**
|
|
1481
|
+
* Resolve a grading artifact path from a unified suite's `pipeline:` block.
|
|
1482
|
+
*
|
|
1483
|
+
* Used by `harness-eval envelope --suite` when `--grading` is omitted (spec C-7).
|
|
1484
|
+
* Checks `pipeline.envelope.grading` then default `pipeline.grade.output` on disk.
|
|
1485
|
+
*/
|
|
1486
|
+
async function resolveGradingArtifactFromSuite(suitePath) {
|
|
1487
|
+
let doc;
|
|
1488
|
+
try {
|
|
1489
|
+
doc = await loadSuiteDocument(suitePath);
|
|
1490
|
+
} catch {
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
if (!doc.pipeline) return void 0;
|
|
1494
|
+
const explicit = doc.pipeline.envelope?.grading;
|
|
1495
|
+
if (explicit && await pathExists(explicit)) return explicit;
|
|
1496
|
+
const defaultGrade = doc.pipeline.grade?.output;
|
|
1497
|
+
if (defaultGrade && await pathExists(defaultGrade)) return defaultGrade;
|
|
1498
|
+
}
|
|
1499
|
+
/** Parse `--steps run,grade,envelope` against configured pipeline keys. */
|
|
1500
|
+
function parsePipelineSteps(pipeline, stepsArg) {
|
|
1501
|
+
const configured = [];
|
|
1502
|
+
if (pipeline.run !== void 0) configured.push("run");
|
|
1503
|
+
if (pipeline.grade !== void 0) configured.push("grade");
|
|
1504
|
+
if (pipeline.envelope !== void 0) configured.push("envelope");
|
|
1505
|
+
if (configured.length === 0) throw new ConfigError("pipeline block has no steps configured");
|
|
1506
|
+
if (!stepsArg) return configured;
|
|
1507
|
+
const validStepNames = /* @__PURE__ */ new Set([
|
|
1508
|
+
"run",
|
|
1509
|
+
"grade",
|
|
1510
|
+
"envelope"
|
|
1511
|
+
]);
|
|
1512
|
+
const requested = stepsArg.split(",").map((s) => s.trim()).filter(Boolean);
|
|
1513
|
+
for (const step of requested) {
|
|
1514
|
+
if (!validStepNames.has(step)) throw new ConfigError(`unknown pipeline step "${step}"; valid steps are: run, grade, envelope`);
|
|
1515
|
+
if (!configured.includes(step)) throw new ConfigError(`pipeline step "${step}" is not configured in suite.yaml`);
|
|
1516
|
+
}
|
|
1517
|
+
const requestedSet = new Set(requested);
|
|
1518
|
+
return configured.filter((step) => requestedSet.has(step));
|
|
1519
|
+
}
|
|
1520
|
+
/** Parent directory of suite.yaml. */
|
|
1521
|
+
function suiteDirectoryFromPath(suitePath) {
|
|
1522
|
+
return dirname(resolve(suitePath));
|
|
1523
|
+
}
|
|
1524
|
+
//#endregion
|
|
1525
|
+
//#region src/cli/args.ts
|
|
1526
|
+
/** Parse process argv into command, positional args, and options. */
|
|
1527
|
+
function parseArgs(argv) {
|
|
1528
|
+
const positional = [];
|
|
1529
|
+
const options = {};
|
|
1530
|
+
let command;
|
|
1531
|
+
const args = [...argv];
|
|
1532
|
+
if (args.length > 0 && !args[0].startsWith("-")) command = args.shift();
|
|
1533
|
+
for (let i = 0; i < args.length; i++) {
|
|
1534
|
+
const arg = args[i];
|
|
1535
|
+
if (arg === "--") {
|
|
1536
|
+
positional.push(...args.slice(i + 1));
|
|
1537
|
+
break;
|
|
1538
|
+
}
|
|
1539
|
+
if (arg.startsWith("--")) {
|
|
1540
|
+
const key = arg.slice(2);
|
|
1541
|
+
const next = args[i + 1];
|
|
1542
|
+
if (next && !next.startsWith("-")) {
|
|
1543
|
+
options[key] = next;
|
|
1544
|
+
i++;
|
|
1545
|
+
} else options[key] = true;
|
|
1546
|
+
} else if (arg.startsWith("-") && arg.length === 2) {
|
|
1547
|
+
const key = arg.slice(1);
|
|
1548
|
+
const next = args[i + 1];
|
|
1549
|
+
if (next && !next.startsWith("-")) {
|
|
1550
|
+
options[key] = next;
|
|
1551
|
+
i++;
|
|
1552
|
+
} else options[key] = true;
|
|
1553
|
+
} else positional.push(arg);
|
|
1554
|
+
}
|
|
1555
|
+
return {
|
|
1556
|
+
command,
|
|
1557
|
+
positional,
|
|
1558
|
+
options
|
|
1559
|
+
};
|
|
1560
|
+
}
|
|
1561
|
+
/** Return a string option value, or undefined when absent or boolean. */
|
|
1562
|
+
function getOption(options, name) {
|
|
1563
|
+
const v = options[name];
|
|
1564
|
+
return typeof v === "string" ? v : void 0;
|
|
1565
|
+
}
|
|
1566
|
+
/** Parse an integer option with fallback when absent or non-numeric. */
|
|
1567
|
+
function getOptionInt(options, name, defaultValue) {
|
|
1568
|
+
const v = getOption(options, name);
|
|
1569
|
+
if (v === void 0) return defaultValue;
|
|
1570
|
+
const n = Number.parseInt(v, 10);
|
|
1571
|
+
if (!Number.isFinite(n)) return defaultValue;
|
|
1572
|
+
return n;
|
|
1573
|
+
}
|
|
1574
|
+
/** True when a boolean flag is set or explicitly `"true"`. */
|
|
1575
|
+
function hasOption(options, name) {
|
|
1576
|
+
const v = options[name];
|
|
1577
|
+
return v === true || typeof v === "string" && v === "true";
|
|
1578
|
+
}
|
|
1579
|
+
//#endregion
|
|
1580
|
+
//#region src/cli/commands/envelope.ts
|
|
1581
|
+
/**
|
|
1582
|
+
* `harness-eval envelope` — build EvalRunEnvelope and interchange projections.
|
|
1583
|
+
*
|
|
1584
|
+
* Reads a suite run report (and optional grading JSON), builds a versioned
|
|
1585
|
+
* {@link EvalRunEnvelope}, and serializes one of three projections:
|
|
1586
|
+
*
|
|
1587
|
+
* - `envelope` — full nested JSON document (default)
|
|
1588
|
+
* - `trajectory` — JSONL of {@link EvalDatasetRow} per repetition
|
|
1589
|
+
* - `instances` — JSONL of {@link InstancesJsonlRow} for Vertex batch upload
|
|
1590
|
+
*
|
|
1591
|
+
* Exit code 0 when behavioral pass, 1 when any cell failed assertions.
|
|
1592
|
+
*/
|
|
1593
|
+
const PROJECTIONS = /* @__PURE__ */ new Set([
|
|
1594
|
+
"envelope",
|
|
1595
|
+
"trajectory",
|
|
1596
|
+
"instances"
|
|
1597
|
+
]);
|
|
1598
|
+
/**
|
|
1599
|
+
* Parse and validate `--projection` CLI flag.
|
|
1600
|
+
*
|
|
1601
|
+
* @returns `"envelope"` when omitted; `undefined` when value is invalid.
|
|
1602
|
+
*/
|
|
1603
|
+
function parseEnvelopeProjection(value) {
|
|
1604
|
+
if (value === void 0) return "envelope";
|
|
1605
|
+
if (PROJECTIONS.has(value)) return value;
|
|
1606
|
+
}
|
|
1607
|
+
/**
|
|
1608
|
+
* Serialize an envelope to stdout/file string for the chosen projection.
|
|
1609
|
+
*
|
|
1610
|
+
* Trajectory and instances projections emit NDJSON (one JSON object per line).
|
|
1611
|
+
*/
|
|
1612
|
+
function serializeEnvelopeProjection(envelope, projection) {
|
|
1613
|
+
switch (projection) {
|
|
1614
|
+
case "trajectory": return `${toTrajectory(envelope).map((row) => JSON.stringify(row)).join("\n")}\n`;
|
|
1615
|
+
case "instances": return `${toInstancesJsonl(envelope).map((row) => JSON.stringify(row)).join("\n")}\n`;
|
|
1616
|
+
default: return `${JSON.stringify(envelope, null, 2)}\n`;
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
/** Read harness-eval package version for envelope harness.frameworkVersion. */
|
|
1620
|
+
async function readFrameworkVersion() {
|
|
1621
|
+
try {
|
|
1622
|
+
const text = await readFile(join(dirname(fileURLToPath(import.meta.url)), "../../../package.json"), "utf8");
|
|
1623
|
+
return JSON.parse(text).version;
|
|
1624
|
+
} catch {
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
/**
|
|
1629
|
+
* CLI entry point for the `envelope` subcommand.
|
|
1630
|
+
*
|
|
1631
|
+
* @returns Process exit code: 0 on behavioral pass, 1 on failure, 2 on usage/error.
|
|
1632
|
+
*/
|
|
1633
|
+
async function envelopeCommand(args) {
|
|
1634
|
+
const reportPath = args.positional[0];
|
|
1635
|
+
if (!reportPath) {
|
|
1636
|
+
console.error("usage: harness-eval envelope <report.json> [--output path] [--grading path] [--suite path] [--projection envelope|trajectory|instances] [--include-raw-stream-events] [--no-transcript]");
|
|
1637
|
+
return 2;
|
|
1638
|
+
}
|
|
1639
|
+
const outputPath = getOption(args.options, "output");
|
|
1640
|
+
const suitePath = getOption(args.options, "suite");
|
|
1641
|
+
let gradingPath = getOption(args.options, "grading");
|
|
1642
|
+
if (!gradingPath && suitePath) gradingPath = await resolveGradingArtifactFromSuite(suitePath);
|
|
1643
|
+
const projection = parseEnvelopeProjection(getOption(args.options, "projection"));
|
|
1644
|
+
if (!projection) {
|
|
1645
|
+
console.error("invalid --projection; expected envelope, trajectory, or instances");
|
|
1646
|
+
return 2;
|
|
1647
|
+
}
|
|
1648
|
+
let envelope;
|
|
1649
|
+
try {
|
|
1650
|
+
const frameworkVersion = await readFrameworkVersion();
|
|
1651
|
+
envelope = await buildEvalRunEnvelopeFromFiles(reportPath, {
|
|
1652
|
+
gradingPath,
|
|
1653
|
+
suitePath,
|
|
1654
|
+
includeTranscript: !hasOption(args.options, "no-transcript"),
|
|
1655
|
+
includeRawStreamEvents: hasOption(args.options, "include-raw-stream-events"),
|
|
1656
|
+
harness: { frameworkVersion }
|
|
1657
|
+
});
|
|
1658
|
+
} catch (err) {
|
|
1659
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1660
|
+
return 2;
|
|
1661
|
+
}
|
|
1662
|
+
const serialized = serializeEnvelopeProjection(envelope, projection);
|
|
1663
|
+
if (outputPath) await writeFile(outputPath, serialized, "utf8");
|
|
1664
|
+
else process.stdout.write(serialized);
|
|
1665
|
+
return envelope.summary.behavioralPass ? 0 : 1;
|
|
1666
|
+
}
|
|
1667
|
+
//#endregion
|
|
1668
|
+
//#region src/pipeline/run-pipeline.ts
|
|
1669
|
+
/**
|
|
1670
|
+
* Orchestrate run → grade → envelope pipeline steps.
|
|
1671
|
+
*/
|
|
1672
|
+
/** Execute configured pipeline steps in order; stop on first failure. */
|
|
1673
|
+
async function runPipeline(doc, options = {}) {
|
|
1674
|
+
if (!doc.pipeline) throw new ConfigError("suite document has no pipeline block", doc.suitePath);
|
|
1675
|
+
const steps = parsePipelineSteps(doc.pipeline, options.steps);
|
|
1676
|
+
const suiteDir = suiteDirectoryFromPath(doc.suitePath);
|
|
1677
|
+
const executed = {};
|
|
1678
|
+
let runReport;
|
|
1679
|
+
let exitCode = 0;
|
|
1680
|
+
for (const step of steps) {
|
|
1681
|
+
const resolved = await resolvePipelineInputs({
|
|
1682
|
+
suitePath: doc.suitePath,
|
|
1683
|
+
suiteDir,
|
|
1684
|
+
pipeline: doc.pipeline,
|
|
1685
|
+
steps: [step],
|
|
1686
|
+
executed,
|
|
1687
|
+
overrides: options.overrides
|
|
1688
|
+
});
|
|
1689
|
+
if (step === "run" && resolved.run) {
|
|
1690
|
+
const adapter = getAdapter(doc.suite.adapter ?? "claude-code");
|
|
1691
|
+
runReport = await runSuite(doc.suite, {
|
|
1692
|
+
adapter,
|
|
1693
|
+
maxConcurrent: resolved.run.maxConcurrent ?? options.maxConcurrent ?? 4,
|
|
1694
|
+
onProgress: options.onRunProgress
|
|
1695
|
+
});
|
|
1696
|
+
await writeFile(resolved.run.output, JSON.stringify(runReport, null, 2), "utf8");
|
|
1697
|
+
executed.run = { output: resolved.run.output };
|
|
1698
|
+
if (!runReport.cells.every((cell) => cell.passed)) return {
|
|
1699
|
+
exitCode: 1,
|
|
1700
|
+
stepsRun: steps.slice(0, steps.indexOf(step) + 1),
|
|
1701
|
+
runReport
|
|
1702
|
+
};
|
|
1703
|
+
continue;
|
|
1704
|
+
}
|
|
1705
|
+
if (step === "grade" && resolved.grade) {
|
|
1706
|
+
if (!doc.judge) throw new ConfigError("grade step requires inline judge: block in suite.yaml", doc.suitePath);
|
|
1707
|
+
const gradeOptions = resolveGradeOptions({ judge: doc.judge }, {
|
|
1708
|
+
sourceReport: resolved.grade.input,
|
|
1709
|
+
maxConcurrent: resolved.grade.maxConcurrent
|
|
1710
|
+
}, doc.suitePath);
|
|
1711
|
+
const grading = await gradeReport(await loadSuiteReport(resolved.grade.input), {
|
|
1712
|
+
...gradeOptions,
|
|
1713
|
+
onProgress: options.onGradeProgress
|
|
1714
|
+
});
|
|
1715
|
+
await writeFile(resolved.grade.output, JSON.stringify(grading, null, 2), "utf8");
|
|
1716
|
+
executed.grade = {
|
|
1717
|
+
input: resolved.grade.input,
|
|
1718
|
+
output: resolved.grade.output
|
|
1719
|
+
};
|
|
1720
|
+
if (!gradingReportPassed(grading)) return {
|
|
1721
|
+
exitCode: 1,
|
|
1722
|
+
stepsRun: steps.slice(0, steps.indexOf(step) + 1),
|
|
1723
|
+
runReport
|
|
1724
|
+
};
|
|
1725
|
+
continue;
|
|
1726
|
+
}
|
|
1727
|
+
if (step === "envelope" && resolved.envelope) {
|
|
1728
|
+
const envelope = await buildEvalRunEnvelopeFromFiles(resolved.envelope.report, {
|
|
1729
|
+
gradingPath: resolved.envelope.grading,
|
|
1730
|
+
suitePath: doc.suitePath,
|
|
1731
|
+
includeTranscript: !resolved.envelope.noTranscript,
|
|
1732
|
+
includeRawStreamEvents: resolved.envelope.includeRawStreamEvents,
|
|
1733
|
+
harness: { frameworkVersion: options.frameworkVersion }
|
|
1734
|
+
});
|
|
1735
|
+
const serialized = serializeEnvelopeProjection(envelope, resolved.envelope.projection);
|
|
1736
|
+
await writeFile(resolved.envelope.output, serialized, "utf8");
|
|
1737
|
+
const behavioralFail = !envelope.summary.behavioralPass;
|
|
1738
|
+
const outcomeFail = envelope.summary.outcomePass !== void 0 && !envelope.summary.outcomePass;
|
|
1739
|
+
if (behavioralFail || outcomeFail) return {
|
|
1740
|
+
exitCode: 1,
|
|
1741
|
+
stepsRun: steps.slice(0, steps.indexOf(step) + 1),
|
|
1742
|
+
runReport
|
|
1743
|
+
};
|
|
1744
|
+
continue;
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
return {
|
|
1748
|
+
exitCode,
|
|
1749
|
+
stepsRun: steps,
|
|
1750
|
+
runReport
|
|
1751
|
+
};
|
|
1752
|
+
}
|
|
1753
|
+
//#endregion
|
|
1754
|
+
//#region src/otel/attributes.ts
|
|
1755
|
+
/** Build a string-typed OTLP attribute. */
|
|
1756
|
+
function strAttr(key, value) {
|
|
1757
|
+
return {
|
|
1758
|
+
key,
|
|
1759
|
+
value: { stringValue: value }
|
|
1760
|
+
};
|
|
1761
|
+
}
|
|
1762
|
+
/** Build an integer-typed OTLP attribute (stored as decimal string). */
|
|
1763
|
+
function intAttr(key, value) {
|
|
1764
|
+
return {
|
|
1765
|
+
key,
|
|
1766
|
+
value: { intValue: String(value) }
|
|
1767
|
+
};
|
|
1768
|
+
}
|
|
1769
|
+
/** Build a boolean-typed OTLP attribute. */
|
|
1770
|
+
function boolAttr(key, value) {
|
|
1771
|
+
return {
|
|
1772
|
+
key,
|
|
1773
|
+
value: { boolValue: value }
|
|
1774
|
+
};
|
|
1775
|
+
}
|
|
1776
|
+
/** Build a JSON-serialized string attribute (common for message arrays). */
|
|
1777
|
+
function jsonAttr(key, value) {
|
|
1778
|
+
return {
|
|
1779
|
+
key,
|
|
1780
|
+
value: { stringValue: JSON.stringify(value) }
|
|
1781
|
+
};
|
|
1782
|
+
}
|
|
1783
|
+
//#endregion
|
|
1784
|
+
//#region src/otel/messages.ts
|
|
1785
|
+
/**
|
|
1786
|
+
* Map harness stop reasons to GenAI semconv finish_reason values.
|
|
1787
|
+
*
|
|
1788
|
+
* Unknown reasons pass through unchanged for forward compatibility.
|
|
1789
|
+
*/
|
|
1790
|
+
function mapStopReason(reason) {
|
|
1791
|
+
if (!reason) return void 0;
|
|
1792
|
+
switch (reason) {
|
|
1793
|
+
case "end_turn": return "stop";
|
|
1794
|
+
case "tool_use": return "tool_calls";
|
|
1795
|
+
case "max_tokens": return "length";
|
|
1796
|
+
case "stop_sequence": return "stop";
|
|
1797
|
+
default: return reason;
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
/** Build a tool_call part from a {@link ToolCall}. */
|
|
1801
|
+
function toolCallPart(call) {
|
|
1802
|
+
return {
|
|
1803
|
+
type: "tool_call",
|
|
1804
|
+
id: call.callId,
|
|
1805
|
+
name: call.name,
|
|
1806
|
+
arguments: call.args ?? {}
|
|
1807
|
+
};
|
|
1808
|
+
}
|
|
1809
|
+
/** Build a tool_call_response part from a {@link ToolCall} result. */
|
|
1810
|
+
function toolResponsePart(call) {
|
|
1811
|
+
return {
|
|
1812
|
+
type: "tool_call_response",
|
|
1813
|
+
id: call.callId,
|
|
1814
|
+
result: call.result
|
|
1815
|
+
};
|
|
1816
|
+
}
|
|
1817
|
+
/** Convert one assistant turn to a GenAI semconv assistant message. */
|
|
1818
|
+
function assistantMessageFromTurn(turn) {
|
|
1819
|
+
const parts = [];
|
|
1820
|
+
if (turn.text) parts.push({
|
|
1821
|
+
type: "text",
|
|
1822
|
+
content: turn.text
|
|
1823
|
+
});
|
|
1824
|
+
for (const call of turn.toolCalls) parts.push(toolCallPart(call));
|
|
1825
|
+
const finish = mapStopReason(turn.stopReason);
|
|
1826
|
+
return {
|
|
1827
|
+
role: "assistant",
|
|
1828
|
+
parts,
|
|
1829
|
+
...finish ? { finish_reason: finish } : {}
|
|
1830
|
+
};
|
|
1831
|
+
}
|
|
1832
|
+
/** Aggregate tool results from a turn into a single tool-role message, if any. */
|
|
1833
|
+
function toolResultsMessage(calls) {
|
|
1834
|
+
const parts = calls.filter((c) => c.result !== null).map((c) => toolResponsePart(c));
|
|
1835
|
+
if (parts.length === 0) return null;
|
|
1836
|
+
return {
|
|
1837
|
+
role: "tool",
|
|
1838
|
+
parts
|
|
1839
|
+
};
|
|
1840
|
+
}
|
|
1841
|
+
/**
|
|
1842
|
+
* Input history before the assistant turn at `turnIndex`.
|
|
1843
|
+
*/
|
|
1844
|
+
function inputMessagesBeforeTurn(view, turnIndex, prompt) {
|
|
1845
|
+
const messages = [];
|
|
1846
|
+
if (prompt) messages.push({
|
|
1847
|
+
role: "user",
|
|
1848
|
+
parts: [{
|
|
1849
|
+
type: "text",
|
|
1850
|
+
content: prompt
|
|
1851
|
+
}]
|
|
1852
|
+
});
|
|
1853
|
+
for (let i = 0; i < turnIndex; i++) {
|
|
1854
|
+
const turn = view.turns[i];
|
|
1855
|
+
if (!turn) continue;
|
|
1856
|
+
messages.push(assistantMessageFromTurn(turn));
|
|
1857
|
+
const toolMsg = toolResultsMessage(turn.toolCalls);
|
|
1858
|
+
if (toolMsg) messages.push(toolMsg);
|
|
1859
|
+
}
|
|
1860
|
+
return messages;
|
|
1861
|
+
}
|
|
1862
|
+
//#endregion
|
|
1863
|
+
//#region src/otel/types.ts
|
|
1864
|
+
/** OTLP span kinds (enum integers). */
|
|
1865
|
+
const SpanKind = {
|
|
1866
|
+
INTERNAL: 1,
|
|
1867
|
+
CLIENT: 2
|
|
1868
|
+
};
|
|
1869
|
+
/** OTLP status codes. */
|
|
1870
|
+
const StatusCode = {
|
|
1871
|
+
UNSET: 0,
|
|
1872
|
+
OK: 1,
|
|
1873
|
+
ERROR: 2
|
|
1874
|
+
};
|
|
1875
|
+
//#endregion
|
|
1876
|
+
//#region src/otel/emitter.ts
|
|
1877
|
+
/**
|
|
1878
|
+
* TrajectoryView → OTLP JSON export using OpenTelemetry GenAI semantic conventions.
|
|
1879
|
+
*
|
|
1880
|
+
* Produces an `ExportTraceServiceRequest` suitable for OTLP/HTTP JSON ingestion.
|
|
1881
|
+
* Assertions continue to use {@link TrajectoryView} directly; this is export-only.
|
|
1882
|
+
*/
|
|
1883
|
+
const INSTRUMENTATION_VERSION = "0.1.0";
|
|
1884
|
+
/**
|
|
1885
|
+
* Map a {@link TrajectoryView} to OTLP trace JSON.
|
|
1886
|
+
*
|
|
1887
|
+
* Span tree (siblings under `invoke_agent`, not nested):
|
|
1888
|
+
* ```
|
|
1889
|
+
* invoke_agent
|
|
1890
|
+
* ├── chat {model}
|
|
1891
|
+
* ├── execute_tool {name}
|
|
1892
|
+
* ├── chat {model}
|
|
1893
|
+
* └── ...
|
|
1894
|
+
* ```
|
|
1895
|
+
*/
|
|
1896
|
+
function trajectoryToOtlp(view, options = {}) {
|
|
1897
|
+
const agentName = options.agentName ?? "claude-code";
|
|
1898
|
+
const providerName = options.providerName ?? "anthropic";
|
|
1899
|
+
const serviceName = options.serviceName ?? "harness-eval";
|
|
1900
|
+
const scopeName = options.instrumentationScope ?? "@alis-build/harness-eval";
|
|
1901
|
+
const traceId = traceIdFromSession(view.meta.sessionId);
|
|
1902
|
+
const rootSpanId = spanIdFromKey(traceId, "invoke_agent");
|
|
1903
|
+
const durationMs = Math.max(view.usage.durationMs, 1);
|
|
1904
|
+
const endMs = options.endTimeMs ?? Date.now();
|
|
1905
|
+
const startMs = endMs - durationMs;
|
|
1906
|
+
const rootStartNs = msToNs(startMs);
|
|
1907
|
+
const rootEndNs = msToNs(endMs);
|
|
1908
|
+
const spans = [];
|
|
1909
|
+
const timings = buildSpanTimings(view, startMs, endMs);
|
|
1910
|
+
spans.push({
|
|
1911
|
+
traceId,
|
|
1912
|
+
spanId: rootSpanId,
|
|
1913
|
+
name: "invoke_agent",
|
|
1914
|
+
kind: SpanKind.INTERNAL,
|
|
1915
|
+
startTimeUnixNano: rootStartNs,
|
|
1916
|
+
endTimeUnixNano: rootEndNs,
|
|
1917
|
+
attributes: [
|
|
1918
|
+
strAttr("gen_ai.operation.name", "invoke_agent"),
|
|
1919
|
+
strAttr("gen_ai.agent.name", agentName),
|
|
1920
|
+
strAttr("gen_ai.provider.name", providerName),
|
|
1921
|
+
strAttr("gen_ai.conversation.id", view.meta.sessionId),
|
|
1922
|
+
strAttr("gen_ai.request.model", view.meta.model),
|
|
1923
|
+
strAttr("gen_ai.response.model", view.meta.model),
|
|
1924
|
+
intAttr("gen_ai.usage.input_tokens", view.usage.inputTokens),
|
|
1925
|
+
intAttr("gen_ai.usage.output_tokens", view.usage.outputTokens),
|
|
1926
|
+
boolAttr("harness_eval.success", view.success)
|
|
1927
|
+
],
|
|
1928
|
+
status: viewStatus(view)
|
|
1929
|
+
});
|
|
1930
|
+
let opIndex = 0;
|
|
1931
|
+
for (const turn of view.turns) {
|
|
1932
|
+
const chatTiming = timings[opIndex++];
|
|
1933
|
+
const chatSpanId = spanIdFromKey(traceId, `chat:${turn.turnIndex}`);
|
|
1934
|
+
const inputMessages = inputMessagesBeforeTurn(view, turn.turnIndex, options.prompt);
|
|
1935
|
+
const outputMessages = [assistantMessageFromTurn(turn)];
|
|
1936
|
+
spans.push({
|
|
1937
|
+
traceId,
|
|
1938
|
+
spanId: chatSpanId,
|
|
1939
|
+
parentSpanId: rootSpanId,
|
|
1940
|
+
name: `chat ${view.meta.model}`,
|
|
1941
|
+
kind: SpanKind.CLIENT,
|
|
1942
|
+
startTimeUnixNano: chatTiming.startNs,
|
|
1943
|
+
endTimeUnixNano: chatTiming.endNs,
|
|
1944
|
+
attributes: [
|
|
1945
|
+
strAttr("gen_ai.operation.name", "chat"),
|
|
1946
|
+
strAttr("gen_ai.provider.name", providerName),
|
|
1947
|
+
strAttr("gen_ai.request.model", view.meta.model),
|
|
1948
|
+
strAttr("gen_ai.response.model", view.meta.model),
|
|
1949
|
+
...inputMessages.length > 0 ? [jsonAttr("gen_ai.input.messages", inputMessages)] : [],
|
|
1950
|
+
jsonAttr("gen_ai.output.messages", outputMessages),
|
|
1951
|
+
...turn.stopReason ? [jsonAttr("gen_ai.response.finish_reasons", [mapStopReason(turn.stopReason) ?? turn.stopReason])] : []
|
|
1952
|
+
],
|
|
1953
|
+
status: { code: StatusCode.OK }
|
|
1954
|
+
});
|
|
1955
|
+
if (turn.toolCalls.length === 0) continue;
|
|
1956
|
+
const toolTiming = timings[opIndex++];
|
|
1957
|
+
for (const call of turn.toolCalls) {
|
|
1958
|
+
const toolSpanId = spanIdFromKey(traceId, `tool:${call.callId}`);
|
|
1959
|
+
spans.push({
|
|
1960
|
+
traceId,
|
|
1961
|
+
spanId: toolSpanId,
|
|
1962
|
+
parentSpanId: rootSpanId,
|
|
1963
|
+
name: `execute_tool ${call.name}`,
|
|
1964
|
+
kind: SpanKind.INTERNAL,
|
|
1965
|
+
startTimeUnixNano: toolTiming.startNs,
|
|
1966
|
+
endTimeUnixNano: toolTiming.endNs,
|
|
1967
|
+
attributes: [
|
|
1968
|
+
strAttr("gen_ai.operation.name", "execute_tool"),
|
|
1969
|
+
strAttr("gen_ai.provider.name", providerName),
|
|
1970
|
+
strAttr("gen_ai.tool.name", call.name),
|
|
1971
|
+
strAttr("gen_ai.tool.call.id", call.callId),
|
|
1972
|
+
jsonAttr("gen_ai.tool.call.arguments", call.args ?? {}),
|
|
1973
|
+
...call.result !== null ? [jsonAttr("gen_ai.tool.call.result", call.result)] : [],
|
|
1974
|
+
...call.namespace ? [strAttr("harness_eval.tool.namespace", call.namespace)] : [],
|
|
1975
|
+
boolAttr("harness_eval.tool.is_error", call.isError)
|
|
1976
|
+
],
|
|
1977
|
+
status: call.isError ? {
|
|
1978
|
+
code: StatusCode.ERROR,
|
|
1979
|
+
message: "tool reported error"
|
|
1980
|
+
} : { code: StatusCode.OK }
|
|
1981
|
+
});
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
return { resourceSpans: [{
|
|
1985
|
+
resource: { attributes: [strAttr("service.name", serviceName), strAttr("gen_ai.agent.name", agentName)] },
|
|
1986
|
+
scopeSpans: [{
|
|
1987
|
+
scope: {
|
|
1988
|
+
name: scopeName,
|
|
1989
|
+
version: INSTRUMENTATION_VERSION
|
|
1990
|
+
},
|
|
1991
|
+
spans
|
|
1992
|
+
}]
|
|
1993
|
+
}] };
|
|
1994
|
+
}
|
|
1995
|
+
/** Alias for {@link trajectoryToOtlp} — matches implementation plan naming. */
|
|
1996
|
+
const emitOtel = trajectoryToOtlp;
|
|
1997
|
+
/** Map view success flag to OTLP span status on the root invoke_agent span. */
|
|
1998
|
+
function viewStatus(view) {
|
|
1999
|
+
if (view.success) return { code: StatusCode.OK };
|
|
2000
|
+
return {
|
|
2001
|
+
code: StatusCode.ERROR,
|
|
2002
|
+
message: "harness run did not complete successfully"
|
|
2003
|
+
};
|
|
2004
|
+
}
|
|
2005
|
+
/**
|
|
2006
|
+
* Assign synthetic timestamps to chat and tool spans.
|
|
2007
|
+
*
|
|
2008
|
+
* Stream-json does not carry per-turn wall times, so we divide the session
|
|
2009
|
+
* duration evenly across chat/tool slots for OTLP consumers that require
|
|
2010
|
+
* start/end times on every span.
|
|
2011
|
+
*/
|
|
2012
|
+
function buildSpanTimings(view, startMs, endMs) {
|
|
2013
|
+
const slots = [];
|
|
2014
|
+
for (const turn of view.turns) {
|
|
2015
|
+
slots.push("chat");
|
|
2016
|
+
if (turn.toolCalls.length > 0) slots.push("tools");
|
|
2017
|
+
}
|
|
2018
|
+
if (slots.length === 0) return [];
|
|
2019
|
+
const slotMs = Math.max(endMs - startMs, 1) / slots.length;
|
|
2020
|
+
const timings = [];
|
|
2021
|
+
let offset = startMs;
|
|
2022
|
+
for (const slot of slots) {
|
|
2023
|
+
const slotStart = offset;
|
|
2024
|
+
const slotEnd = offset + slotMs;
|
|
2025
|
+
timings.push({
|
|
2026
|
+
startNs: msToNs(slotStart),
|
|
2027
|
+
endNs: msToNs(slotEnd)
|
|
2028
|
+
});
|
|
2029
|
+
offset = slotEnd;
|
|
2030
|
+
}
|
|
2031
|
+
return timings;
|
|
2032
|
+
}
|
|
2033
|
+
/**
|
|
2034
|
+
* Derive a deterministic 128-bit trace id from the harness session id.
|
|
1537
2035
|
*
|
|
1538
|
-
*
|
|
1539
|
-
* @param options - Same build options as {@link buildEvalRunEnvelope}, plus file paths.
|
|
2036
|
+
* Uses SHA-256 truncation so the same session always maps to the same trace.
|
|
1540
2037
|
*/
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
};
|
|
1554
|
-
}
|
|
1555
|
-
let suite = options.suite;
|
|
1556
|
-
if (options.suitePath) {
|
|
1557
|
-
const content = await readFile(options.suitePath, "utf8");
|
|
1558
|
-
suite = {
|
|
1559
|
-
...suite,
|
|
1560
|
-
uri: options.suitePath,
|
|
1561
|
-
contentHash: createHash("sha256").update(content).digest("hex")
|
|
1562
|
-
};
|
|
1563
|
-
}
|
|
1564
|
-
return buildEvalRunEnvelope(report, {
|
|
1565
|
-
...options,
|
|
1566
|
-
suite,
|
|
1567
|
-
grading
|
|
1568
|
-
});
|
|
2038
|
+
function traceIdFromSession(sessionId) {
|
|
2039
|
+
return createHash("sha256").update(`harness-eval:trace:${sessionId}`).digest("hex").slice(0, 32).toUpperCase();
|
|
2040
|
+
}
|
|
2041
|
+
/**
|
|
2042
|
+
* Derive a deterministic 64-bit span id from trace id and a logical span key.
|
|
2043
|
+
*/
|
|
2044
|
+
function spanIdFromKey(traceId, key) {
|
|
2045
|
+
return createHash("sha256").update(`${traceId}:span:${key}`).digest("hex").slice(0, 16).toUpperCase();
|
|
2046
|
+
}
|
|
2047
|
+
/** Convert milliseconds since epoch to OTLP nanosecond timestamp string. */
|
|
2048
|
+
function msToNs(ms) {
|
|
2049
|
+
return String(Math.round(ms * 1e6));
|
|
1569
2050
|
}
|
|
1570
2051
|
//#endregion
|
|
1571
|
-
//#region src/
|
|
1572
|
-
|
|
1573
|
-
const
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
"anyOrderMatch",
|
|
1577
|
-
"precision",
|
|
1578
|
-
"recall",
|
|
1579
|
-
"singleToolUse"
|
|
1580
|
-
];
|
|
2052
|
+
//#region src/reporter/format-console.ts
|
|
2053
|
+
const RESET = "\x1B[0m";
|
|
2054
|
+
const GREEN = "\x1B[32m";
|
|
2055
|
+
const RED = "\x1B[31m";
|
|
2056
|
+
const YELLOW = "\x1B[33m";
|
|
1581
2057
|
/**
|
|
1582
|
-
*
|
|
2058
|
+
* Render renderable rows as ANSI-colored console output.
|
|
1583
2059
|
*
|
|
1584
|
-
*
|
|
1585
|
-
* back to duration-based latency when enrich did not set latencySeconds.
|
|
2060
|
+
* @param color When false, emit plain text without escape codes.
|
|
1586
2061
|
*/
|
|
1587
|
-
function
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
2062
|
+
function formatConsole(rows, color = true) {
|
|
2063
|
+
const lines = [];
|
|
2064
|
+
for (const row of rows) {
|
|
2065
|
+
const status = row.passed ? color ? `${GREEN}PASS${RESET}` : "PASS" : color ? `${RED}FAIL${RESET}` : "FAIL";
|
|
2066
|
+
const crashNote = row.adapterErrors > 0 ? ` ${color ? YELLOW : ""}[${row.adapterErrors} adapter errors]${color ? RESET : ""}` : "";
|
|
2067
|
+
lines.push(`${row.caseId} @ ${row.cellLabel} ${status}${crashNote}`);
|
|
2068
|
+
if (row.category) lines.push(` category: ${row.category}`);
|
|
2069
|
+
for (const stat of row.stats) {
|
|
2070
|
+
const marker = stat.meetsThreshold ? color ? `${GREEN}✓${RESET}` : "✓" : color ? `${RED}✗${RESET}` : "✗";
|
|
2071
|
+
const rateStr = formatRate$1(stat);
|
|
2072
|
+
const thresholdPct = (stat.threshold * 100).toFixed(0);
|
|
2073
|
+
let line = ` ├─ ${stat.description}: ${rateStr} [threshold ${thresholdPct}%] ${marker}`;
|
|
2074
|
+
if (stat.delta !== void 0 && stat.baselinePassRate !== void 0) {
|
|
2075
|
+
const arrow = stat.delta >= 0 ? "↑" : "↓";
|
|
2076
|
+
const basePct = (stat.baselinePassRate * 100).toFixed(0);
|
|
2077
|
+
const curPct = (stat.passRate * 100).toFixed(0);
|
|
2078
|
+
const deltaPct = (stat.delta * 100).toFixed(0);
|
|
2079
|
+
line += ` (${basePct}% → ${curPct}% (${arrow}${deltaPct}%))`;
|
|
2080
|
+
}
|
|
2081
|
+
lines.push(line);
|
|
2082
|
+
}
|
|
2083
|
+
lines.push("");
|
|
2084
|
+
}
|
|
2085
|
+
return lines.join("\n").trimEnd();
|
|
2086
|
+
}
|
|
2087
|
+
/** Format pass rate for display, noting when all reps crashed. */
|
|
2088
|
+
function formatRate$1(stat) {
|
|
2089
|
+
if (stat.evaluatedCount === 0) return `0/${stat.totalReps} (all reps crashed)`;
|
|
2090
|
+
const pct = (stat.passRate * 100).toFixed(0);
|
|
2091
|
+
return `${stat.passedCount}/${stat.evaluatedCount} (${pct}%)`;
|
|
1598
2092
|
}
|
|
2093
|
+
//#endregion
|
|
2094
|
+
//#region src/reporter/format-json.ts
|
|
1599
2095
|
/**
|
|
1600
|
-
*
|
|
2096
|
+
* Serialize a suite report as indented JSON (no transformation).
|
|
1601
2097
|
*
|
|
1602
|
-
*
|
|
1603
|
-
* (and therefore no trajectoryInstances block).
|
|
2098
|
+
* Used by `--format json` and `--output` persistence.
|
|
1604
2099
|
*/
|
|
1605
|
-
function
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
});
|
|
2100
|
+
function formatJson(report) {
|
|
2101
|
+
return JSON.stringify(report, null, 2);
|
|
2102
|
+
}
|
|
2103
|
+
//#endregion
|
|
2104
|
+
//#region src/reporter/format-markdown.ts
|
|
2105
|
+
/** Render renderable rows as a GitHub-flavored markdown report. */
|
|
2106
|
+
function formatMarkdown(rows) {
|
|
2107
|
+
const lines = ["# Harness Eval Report", ""];
|
|
2108
|
+
for (const row of rows) {
|
|
2109
|
+
const status = row.passed ? "PASS" : "FAIL";
|
|
2110
|
+
const crashNote = row.adapterErrors > 0 ? ` (${row.adapterErrors} adapter errors)` : "";
|
|
2111
|
+
lines.push(`## ${row.caseId} @ ${row.cellLabel} — ${status}${crashNote}`);
|
|
2112
|
+
if (row.category) lines.push(`**Category:** ${row.category}`);
|
|
2113
|
+
if (row.notes) lines.push("<details><summary>Notes</summary>", row.notes, "</details>");
|
|
2114
|
+
lines.push("");
|
|
2115
|
+
lines.push("| Assertion | Result | Threshold | Status |");
|
|
2116
|
+
lines.push("| --- | --- | --- | --- |");
|
|
2117
|
+
for (const stat of row.stats) {
|
|
2118
|
+
const rateStr = formatRate(stat);
|
|
2119
|
+
const threshold = `${(stat.threshold * 100).toFixed(0)}%`;
|
|
2120
|
+
const statusCell = stat.meetsThreshold ? "✓" : "✗";
|
|
2121
|
+
let result = rateStr;
|
|
2122
|
+
if (stat.delta !== void 0 && stat.baselinePassRate !== void 0) {
|
|
2123
|
+
const base = (stat.baselinePassRate * 100).toFixed(0);
|
|
2124
|
+
const cur = (stat.passRate * 100).toFixed(0);
|
|
2125
|
+
const d = (stat.delta * 100).toFixed(0);
|
|
2126
|
+
const sign = stat.delta >= 0 ? "+" : "";
|
|
2127
|
+
result += ` (${base}% → ${cur}%, ${sign}${d}%)`;
|
|
2128
|
+
}
|
|
2129
|
+
lines.push(`| ${stat.description} | ${result} | ${threshold} | ${statusCell} |`);
|
|
2130
|
+
}
|
|
2131
|
+
lines.push("");
|
|
1617
2132
|
}
|
|
1618
|
-
return
|
|
2133
|
+
return lines.join("\n").trimEnd();
|
|
2134
|
+
}
|
|
2135
|
+
/** Format pass rate for markdown tables, noting when all reps crashed. */
|
|
2136
|
+
function formatRate(stat) {
|
|
2137
|
+
if (stat.evaluatedCount === 0) return `0/${stat.totalReps} (all reps crashed)`;
|
|
2138
|
+
const pct = (stat.passRate * 100).toFixed(0);
|
|
2139
|
+
return `${stat.passedCount}/${stat.evaluatedCount} (${pct}%)`;
|
|
2140
|
+
}
|
|
2141
|
+
//#endregion
|
|
2142
|
+
//#region src/reporter/renderable.ts
|
|
2143
|
+
/** Map a suite report to formatter-ready rows (one per cell). */
|
|
2144
|
+
function toRenderableRows(report) {
|
|
2145
|
+
return report.cells.map((cell) => cellToRow(cell));
|
|
1619
2146
|
}
|
|
1620
2147
|
/**
|
|
1621
|
-
*
|
|
2148
|
+
* Attach baseline pass-rate deltas to matching rows.
|
|
2149
|
+
*
|
|
2150
|
+
* Rows without a matching baseline cell are returned unchanged.
|
|
1622
2151
|
*/
|
|
1623
|
-
function
|
|
1624
|
-
const
|
|
1625
|
-
|
|
1626
|
-
|
|
2152
|
+
function applyBaseline(rows, baseline) {
|
|
2153
|
+
const baselineMap = new Map(baseline.cells.map((c) => [`${c.caseId}::${c.cell.label}`, c]));
|
|
2154
|
+
return rows.map((row) => {
|
|
2155
|
+
const baseCell = baselineMap.get(`${row.caseId}::${row.cellLabel}`);
|
|
2156
|
+
if (!baseCell) return row;
|
|
2157
|
+
const stats = row.stats.map((stat, i) => {
|
|
2158
|
+
const baseStat = baseCell.assertionStats[i];
|
|
2159
|
+
if (!baseStat) return stat;
|
|
2160
|
+
const delta = stat.passRate - baseStat.passRate;
|
|
2161
|
+
return {
|
|
2162
|
+
...stat,
|
|
2163
|
+
baselinePassRate: baseStat.passRate,
|
|
2164
|
+
delta
|
|
2165
|
+
};
|
|
2166
|
+
});
|
|
2167
|
+
return {
|
|
2168
|
+
...row,
|
|
2169
|
+
stats
|
|
2170
|
+
};
|
|
2171
|
+
});
|
|
2172
|
+
}
|
|
2173
|
+
/** Convert one {@link CellReport} to a {@link RenderableRow}. */
|
|
2174
|
+
function cellToRow(cell) {
|
|
2175
|
+
const totalReps = cell.repetitions.length;
|
|
2176
|
+
const stats = cell.assertionStats.map((s) => ({
|
|
2177
|
+
description: s.description,
|
|
2178
|
+
threshold: s.threshold,
|
|
2179
|
+
passedCount: s.passedCount,
|
|
2180
|
+
evaluatedCount: s.evaluatedCount,
|
|
2181
|
+
totalReps,
|
|
2182
|
+
adapterErrors: cell.adapterErrors,
|
|
2183
|
+
passRate: s.passRate,
|
|
2184
|
+
meetsThreshold: s.meetsThreshold
|
|
2185
|
+
}));
|
|
2186
|
+
return {
|
|
2187
|
+
caseId: cell.caseId,
|
|
2188
|
+
category: cell.category,
|
|
2189
|
+
notes: cell.notes,
|
|
2190
|
+
cellLabel: cell.cell.label,
|
|
2191
|
+
passed: cell.passed,
|
|
2192
|
+
adapterErrors: cell.adapterErrors,
|
|
2193
|
+
totalReps,
|
|
2194
|
+
stats
|
|
2195
|
+
};
|
|
1627
2196
|
}
|
|
2197
|
+
//#endregion
|
|
2198
|
+
//#region src/reporter/index.ts
|
|
1628
2199
|
/**
|
|
1629
|
-
*
|
|
2200
|
+
* Format a {@link SuiteReport} for console, markdown, or JSON output.
|
|
2201
|
+
*
|
|
2202
|
+
* JSON format bypasses the renderable intermediate model and serializes the
|
|
2203
|
+
* report directly. Console and markdown apply optional baseline deltas.
|
|
1630
2204
|
*/
|
|
1631
|
-
function
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
2205
|
+
function formatReport(report, options) {
|
|
2206
|
+
if (options.format === "json") return formatJson(report);
|
|
2207
|
+
let rows = toRenderableRows(report);
|
|
2208
|
+
if (options.baseline) rows = applyBaseline(rows, options.baseline);
|
|
2209
|
+
const useColor = options.color ?? options.format === "console";
|
|
2210
|
+
if (options.format === "markdown") return formatMarkdown(rows);
|
|
2211
|
+
return formatConsole(rows, useColor);
|
|
1635
2212
|
}
|
|
1636
2213
|
//#endregion
|
|
1637
|
-
export {
|
|
2214
|
+
export { serializeToolInput as A, TRAJECTORY_SCHEMA_VERSION as B, trajectoryExactMatch as C, trajectorySingleToolUse as D, trajectoryRecall as E, loadSuiteReport as F, trajectoryToTranscript as I, createCodexGrader as L, gradingReportPassed as M, resolveGradeOptions as N, toEvaluationInstance as O, gradeReport as P, createClaudeGrader as R, trajectoryAnyOrderMatch as S, trajectoryPrecision as T, buildEvalRunEnvelopeFromFiles as _, envelopeCommand as a, computeTrajectoryMetrics as b, getOptionInt as c, resolveGradingArtifactFromSuite as d, resolvePipelineInputs as f, buildEvalRunEnvelope as g, toTrajectory as h, runPipeline as i, formatGradingConsole as j, toTrajectoryInstances as k, hasOption as l, toInstancesJsonl as m, emitOtel as n, parseEnvelopeProjection as o, suiteDirectoryFromPath as p, trajectoryToOtlp as r, getOption as s, formatReport as t, parseArgs as u, enrichRepetitionWithProtojson as v, trajectoryInOrderMatch as w, parseToolInput as x, toHarnessMetrics as y, EVAL_RUN_SCHEMA_VERSION as z };
|
|
1638
2215
|
|
|
1639
|
-
//# sourceMappingURL=
|
|
2216
|
+
//# sourceMappingURL=reporter-Biy-5-9M.js.map
|