@skill-harness/core 0.22.4 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/types.d.ts +8 -2
- package/dist/capture-trace-types.d.ts +3 -0
- package/dist/execution-trace.d.ts +21 -0
- package/dist/execution-trace.js +91 -10
- package/dist/grade.d.ts +14 -4
- package/dist/grade.js +122 -23
- package/dist/provider-failure.d.ts +4 -0
- package/dist/provider-failure.js +13 -2
- package/dist/regate.js +13 -3
- package/dist/regrade.js +7 -6
- package/dist/results.d.ts +3 -1
- package/dist/run.js +18 -4
- package/package.json +1 -1
- package/schemas/results-v2.schema.json +1 -0
- package/schemas/results-v3.schema.json +1 -0
package/dist/adapters/types.d.ts
CHANGED
|
@@ -78,6 +78,8 @@ export interface StructuredRun {
|
|
|
78
78
|
* answering badly. `run.ts` turns this into ERROR — never a model verdict.
|
|
79
79
|
*/
|
|
80
80
|
providerFailure?: string;
|
|
81
|
+
/** Terminal error/abort or missing settlement: unavailable outcome, never a judgeable success. */
|
|
82
|
+
executionFailure?: string;
|
|
81
83
|
}
|
|
82
84
|
export interface HarnessAdapter {
|
|
83
85
|
name: string;
|
|
@@ -104,7 +106,11 @@ export interface HarnessAdapter {
|
|
|
104
106
|
* field is to identify which CLI produced a transcript.
|
|
105
107
|
*
|
|
106
108
|
* Optional so a test double or a future adapter need not implement it; callers
|
|
107
|
-
* treat a missing method exactly like a null answer.
|
|
109
|
+
* treat a missing method exactly like a null answer. A qualification probe must
|
|
110
|
+
* use the same cwd and environment as the execution it qualifies.
|
|
108
111
|
*/
|
|
109
|
-
version?(
|
|
112
|
+
version?(options?: {
|
|
113
|
+
cwd?: string;
|
|
114
|
+
env?: NodeJS.ProcessEnv;
|
|
115
|
+
}): Promise<string | null>;
|
|
110
116
|
}
|
|
@@ -92,6 +92,7 @@ export interface TraceMetrics {
|
|
|
92
92
|
/** Date of the repository price table used for cost_usd; null when unpriced. */
|
|
93
93
|
price_as_of: string | null;
|
|
94
94
|
tool_calls: number;
|
|
95
|
+
/** Legacy field: requested Agent arguments, not verified child launches. */
|
|
95
96
|
delegated_children: number;
|
|
96
97
|
/** Maximum simultaneously outstanding tool calls observed in the pi stream. */
|
|
97
98
|
max_concurrency: number;
|
|
@@ -118,6 +119,8 @@ export interface ExecutionTraceV1 {
|
|
|
118
119
|
* never contained, changing grades on scenarios nobody edited.
|
|
119
120
|
*/
|
|
120
121
|
final_text: string;
|
|
122
|
+
/** Observed terminal state; unknown versions are unqualified, historical Pi 0.83.0 has no settlement field. */
|
|
123
|
+
final_status?: "complete" | "error" | "aborted" | "incomplete" | "truncated" | "unavailable" | "unqualified";
|
|
121
124
|
/** Every tool call in this invocation, in issue order. */
|
|
122
125
|
tool_calls: TraceToolCall[];
|
|
123
126
|
/**
|
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
import type { ExecutionTraceV1 } from "./capture-trace-types.js";
|
|
2
2
|
import type { ModelRef, RunMode } from "./adapters/types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Parse pi's `--mode json` event stream into an `ExecutionTraceV1`.
|
|
5
|
+
*
|
|
6
|
+
* Pinned by the fixtures in `packages/adapters/test/fixtures/pi-json/`. Three
|
|
7
|
+
* event-shape constraints are baked in here and each one is a bug if removed:
|
|
8
|
+
*
|
|
9
|
+
* 1. **Line-at-a-time, never buffered.** `message_update` re-sends the entire
|
|
10
|
+
* accumulated message on every delta, so the stream is quadratic in output
|
|
11
|
+
* length — a trivial three-tool-call run emitted 52 MB wrapping 12 KB of
|
|
12
|
+
* terminal events. The parser consumes an iterable of lines and holds only
|
|
13
|
+
* what it keeps.
|
|
14
|
+
* 2. **The final assistant message only.** pi's print mode — what the judge has
|
|
15
|
+
* always been shown — emits exactly that. Concatenating every assistant text
|
|
16
|
+
* block would add interim narration the transcript has never contained and
|
|
17
|
+
* move verdicts on scenarios nobody edited.
|
|
18
|
+
* 3. **Correlate tool calls by `toolCallId`.** Batched calls execute
|
|
19
|
+
* concurrently and their `end` events arrive in completion order.
|
|
20
|
+
*/
|
|
21
|
+
/** Current execution is qualified only against this exact Pi release. */
|
|
22
|
+
export declare const QUALIFIED_PI_VERSION = "1.0.4";
|
|
23
|
+
export declare function piQualificationFailure(version: string | null): string | null;
|
|
3
24
|
export interface TraceMeta {
|
|
4
25
|
piVersion: string | null;
|
|
5
26
|
subject: ModelRef;
|
package/dist/execution-trace.js
CHANGED
|
@@ -20,6 +20,14 @@ import { redactArgs, redactText } from "./redaction.js";
|
|
|
20
20
|
* 3. **Correlate tool calls by `toolCallId`.** Batched calls execute
|
|
21
21
|
* concurrently and their `end` events arrive in completion order.
|
|
22
22
|
*/
|
|
23
|
+
/** Current execution is qualified only against this exact Pi release. */
|
|
24
|
+
export const QUALIFIED_PI_VERSION = "1.0.4";
|
|
25
|
+
/** Historical raw captures from this version remain readable; this is not fresh-run qualification. */
|
|
26
|
+
const LEGACY_PI_TRACE_VERSION = "0.83.0";
|
|
27
|
+
export function piQualificationFailure(version) {
|
|
28
|
+
return version === QUALIFIED_PI_VERSION ? null
|
|
29
|
+
: `Pi ${version ?? "(version unavailable)"} is unqualified; execution requires exact Pi ${QUALIFIED_PI_VERSION}`;
|
|
30
|
+
}
|
|
23
31
|
/** Events that carry no information a trace keeps, and are large. */
|
|
24
32
|
const SKIPPED = new Set(["message_update", "tool_execution_update"]);
|
|
25
33
|
/** `details` larger than this is dropped rather than persisted. */
|
|
@@ -40,6 +48,13 @@ export function parseTrace(lines, meta) {
|
|
|
40
48
|
let completionCounter = 0;
|
|
41
49
|
let malformedLines = 0;
|
|
42
50
|
let sawTerminal = false;
|
|
51
|
+
const settledContract = meta.piVersion !== LEGACY_PI_TRACE_VERSION;
|
|
52
|
+
const qualificationFailure = settledContract ? piQualificationFailure(meta.piVersion) : null;
|
|
53
|
+
let sawSettled = false;
|
|
54
|
+
let lastStopReason;
|
|
55
|
+
let currentAssistantText = "";
|
|
56
|
+
let eligibleFinal = false;
|
|
57
|
+
const captureErrors = [];
|
|
43
58
|
let finalText = "";
|
|
44
59
|
let lastAssistantText = "";
|
|
45
60
|
let inputTokens = null;
|
|
@@ -60,13 +75,35 @@ export function parseTrace(lines, meta) {
|
|
|
60
75
|
malformedLines++;
|
|
61
76
|
continue;
|
|
62
77
|
}
|
|
78
|
+
if (!ev || typeof ev !== "object" || Array.isArray(ev)) {
|
|
79
|
+
malformedLines++;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
63
82
|
const type = ev.type;
|
|
64
|
-
if (typeof type !== "string"
|
|
83
|
+
if (typeof type !== "string") {
|
|
84
|
+
malformedLines++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (settledContract && ["agent_start", "turn_start", "message_start", "message_update", "message_end", "tool_execution_start", "tool_execution_end"].includes(type)) {
|
|
88
|
+
sawSettled = false;
|
|
89
|
+
if (type !== "tool_execution_end") {
|
|
90
|
+
currentAssistantText = "";
|
|
91
|
+
lastStopReason = undefined;
|
|
92
|
+
eligibleFinal = false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (SKIPPED.has(type))
|
|
65
96
|
continue;
|
|
66
97
|
if (type === "tool_execution_start") {
|
|
67
98
|
const id = str(ev.toolCallId);
|
|
68
|
-
if (!id)
|
|
99
|
+
if (!id) {
|
|
100
|
+
captureErrors.push("tool start without an identity");
|
|
69
101
|
continue;
|
|
102
|
+
}
|
|
103
|
+
if (calls.has(id)) {
|
|
104
|
+
captureErrors.push("duplicate tool start identity");
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
70
107
|
calls.set(id, {
|
|
71
108
|
id,
|
|
72
109
|
name: str(ev.toolName) ?? "(unknown)",
|
|
@@ -83,11 +120,19 @@ export function parseTrace(lines, meta) {
|
|
|
83
120
|
}
|
|
84
121
|
if (type === "tool_execution_end") {
|
|
85
122
|
const id = str(ev.toolCallId);
|
|
86
|
-
if (!id)
|
|
123
|
+
if (!id) {
|
|
124
|
+
captureErrors.push("tool end without an identity");
|
|
87
125
|
continue;
|
|
126
|
+
}
|
|
88
127
|
const call = calls.get(id);
|
|
89
|
-
if (!call)
|
|
90
|
-
|
|
128
|
+
if (!call) {
|
|
129
|
+
captureErrors.push("tool end without a matching start");
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (call.completionIndex >= 0) {
|
|
133
|
+
captureErrors.push("duplicate tool completion identity");
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
91
136
|
if (call.completionIndex < 0)
|
|
92
137
|
activeCalls = Math.max(0, activeCalls - 1);
|
|
93
138
|
call.completionIndex = completionCounter++;
|
|
@@ -100,7 +145,20 @@ export function parseTrace(lines, meta) {
|
|
|
100
145
|
if (type === "message_end") {
|
|
101
146
|
sawTerminal = true;
|
|
102
147
|
const msg = ev.message;
|
|
103
|
-
|
|
148
|
+
if (!msg || typeof msg !== "object" || Array.isArray(msg) || typeof msg.role !== "string") {
|
|
149
|
+
captureErrors.push("malformed message_end message");
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
// Pi also emits system/user messages; a system message has string content.
|
|
153
|
+
// They invalidate a previous final but are not assistant/tool-result payloads.
|
|
154
|
+
if (msg.role !== "assistant" && msg.role !== "toolResult")
|
|
155
|
+
continue;
|
|
156
|
+
if (!Array.isArray(msg.content) ||
|
|
157
|
+
msg.content.some((block) => !block || typeof block !== "object" || Array.isArray(block) || typeof block.type !== "string" || (block.type === "text" && typeof block.text !== "string"))) {
|
|
158
|
+
captureErrors.push("malformed message_end content");
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const at = isoTime(msg.timestamp);
|
|
104
162
|
if (msg?.role === "assistant" && at) {
|
|
105
163
|
for (const block of msg.content ?? []) {
|
|
106
164
|
if (block.type !== "toolCall" || typeof block.id !== "string")
|
|
@@ -120,6 +178,9 @@ export function parseTrace(lines, meta) {
|
|
|
120
178
|
if (msg?.role !== "assistant")
|
|
121
179
|
continue;
|
|
122
180
|
const text = assistantText(msg);
|
|
181
|
+
eligibleFinal = msg.content.every((block) => block.type === "text" || block.type === "thinking");
|
|
182
|
+
currentAssistantText = text;
|
|
183
|
+
lastStopReason = msg.stopReason;
|
|
123
184
|
if (text) {
|
|
124
185
|
lastAssistantText = text;
|
|
125
186
|
// `stop` marks the model's closing message; a `toolUse` message is
|
|
@@ -135,6 +196,8 @@ export function parseTrace(lines, meta) {
|
|
|
135
196
|
}
|
|
136
197
|
if (type === "turn_end" || type === "agent_end" || type === "agent_settled") {
|
|
137
198
|
sawTerminal = true;
|
|
199
|
+
if (type === "agent_settled")
|
|
200
|
+
sawSettled = true;
|
|
138
201
|
// Deliberately read NOTHING from these. They repeat the same assistant
|
|
139
202
|
// messages `message_end` already carried (`turn_end` and `agent_end` do;
|
|
140
203
|
// `agent_settled` carries no keys at all beyond `type`), and reading two
|
|
@@ -143,6 +206,23 @@ export function parseTrace(lines, meta) {
|
|
|
143
206
|
}
|
|
144
207
|
}
|
|
145
208
|
const toolCalls = [...calls.values()].sort((a, b) => a.issueIndex - b.issueIndex);
|
|
209
|
+
if (malformedLines > 0)
|
|
210
|
+
captureErrors.push(`pi JSONL contained ${malformedLines} malformed line(s); trace evidence is incomplete`);
|
|
211
|
+
const finalStatus = qualificationFailure ? "unqualified"
|
|
212
|
+
: !sawSettled || activeCalls > 0 ? "incomplete"
|
|
213
|
+
: captureErrors.length > 0 ? "unavailable"
|
|
214
|
+
: lastStopReason === "error" ? "error"
|
|
215
|
+
: lastStopReason === "aborted" ? "aborted"
|
|
216
|
+
: lastStopReason === "length" ? "truncated"
|
|
217
|
+
: lastStopReason === "stop" && eligibleFinal && currentAssistantText.length > 0 ? "complete" : "unavailable";
|
|
218
|
+
if (settledContract && finalStatus !== "complete") {
|
|
219
|
+
const reason = qualificationFailure
|
|
220
|
+
?? (finalStatus === "incomplete" ? "current settlement is missing or tool calls remain outstanding"
|
|
221
|
+
: finalStatus === "truncated" ? "settled assistant reached its output length limit"
|
|
222
|
+
: finalStatus === "error" || finalStatus === "aborted" ? `settled assistant stop reason is ${lastStopReason}`
|
|
223
|
+
: "current terminal assistant content is empty, ineligible, or captured evidence is malformed");
|
|
224
|
+
captureErrors.push(`Pi ${meta.piVersion ?? "(version unavailable)"} final delivery is ${finalStatus}; ${reason}`);
|
|
225
|
+
}
|
|
146
226
|
const metrics = {
|
|
147
227
|
input_tokens: inputTokens,
|
|
148
228
|
output_tokens: outputTokens,
|
|
@@ -172,7 +252,9 @@ export function parseTrace(lines, meta) {
|
|
|
172
252
|
// and `smoke-real-pi.sh` asserts no `/home/` survives into a persisted trace
|
|
173
253
|
// — an assertion that used to pass only because the smoke model happened not
|
|
174
254
|
// to echo one.
|
|
175
|
-
final_text: redactText(finalText || lastAssistantText, meta.homeDir),
|
|
255
|
+
final_text: redactText(settledContract ? currentAssistantText : finalText || lastAssistantText, meta.homeDir),
|
|
256
|
+
...(settledContract ? { final_status: finalStatus } : {}),
|
|
257
|
+
...(captureErrors.length ? { capture_errors: [...new Set(captureErrors)] } : {}),
|
|
176
258
|
tool_calls: toolCalls,
|
|
177
259
|
// `null`, not `[]`: the stream says nothing about the filesystem. The runner
|
|
178
260
|
// overwrites this after observing the workspace. Defaulting to `[]` claimed
|
|
@@ -184,7 +266,7 @@ export function parseTrace(lines, meta) {
|
|
|
184
266
|
metrics,
|
|
185
267
|
};
|
|
186
268
|
trace.trace_sha256 = traceSha256(trace);
|
|
187
|
-
return { trace, isComplete: sawTerminal, malformedLines };
|
|
269
|
+
return { trace, isComplete: settledContract ? !qualificationFailure && sawSettled && activeCalls === 0 : sawTerminal, malformedLines };
|
|
188
270
|
}
|
|
189
271
|
function addReported(current, value) {
|
|
190
272
|
return typeof value === "number" && value > 0 ? (current ?? 0) + value : current;
|
|
@@ -194,8 +276,7 @@ function assistantText(msg) {
|
|
|
194
276
|
return (msg.content ?? [])
|
|
195
277
|
.filter((b) => b.type === "text" && typeof b.text === "string")
|
|
196
278
|
.map((b) => b.text)
|
|
197
|
-
.join("
|
|
198
|
-
.trim();
|
|
279
|
+
.join("");
|
|
199
280
|
}
|
|
200
281
|
/**
|
|
201
282
|
* Bounded metadata about a tool result. The body is never kept: results carry
|
package/dist/grade.d.ts
CHANGED
|
@@ -36,13 +36,23 @@ export declare function parseVerdict(out: string): ParsedVerdict;
|
|
|
36
36
|
*/
|
|
37
37
|
export declare function judgeResemblesSubject(judge: ModelRef, subject: ModelRef): boolean;
|
|
38
38
|
export interface GradeResult extends ParsedVerdict {
|
|
39
|
+
/** The final judge reply, retained for callers that predate structured judging. */
|
|
39
40
|
raw: string;
|
|
41
|
+
/** Every attempt in call order; two entries mean the structured retry also ran. */
|
|
42
|
+
rawReplies: string[];
|
|
40
43
|
criteria: CriterionVote[];
|
|
41
|
-
|
|
44
|
+
judgeFormat: "json";
|
|
45
|
+
/** One validation-guided retry after an invalid structured reply. Absent on the first attempt. */
|
|
42
46
|
judgeRetries?: 1;
|
|
43
47
|
/** Judge misfire: the overall verdict disagrees with AND(per-item grades). Recorded, never auto-passed; blocks SHIP until re-judged or overridden. */
|
|
44
48
|
suspect: boolean;
|
|
45
49
|
}
|
|
50
|
+
/** Parse and validate the JSON-only contract used for new judge calls. */
|
|
51
|
+
export declare function parseStructuredJudgeReply(raw: string, expectedCriteria: number): ParsedVerdict & {
|
|
52
|
+
criteria: CriterionVote[];
|
|
53
|
+
};
|
|
54
|
+
/** Preserve one raw reply byte-for-byte, or both retry attempts with explicit boundaries. */
|
|
55
|
+
export declare function formatJudgeRawReplies(replies: string[]): string;
|
|
46
56
|
/**
|
|
47
57
|
* Judge-misfire detector: parse the judge's per-checklist-item grades and assert
|
|
48
58
|
* the overall verdict equals AND(items). A mismatch in EITHER direction — verdict
|
|
@@ -51,11 +61,11 @@ export interface GradeResult extends ParsedVerdict {
|
|
|
51
61
|
* parse, or the verdict is ERROR, return false (never block a run on a parse miss).
|
|
52
62
|
*/
|
|
53
63
|
export declare function detectMisfire(raw: string, verdict: Verdict): boolean;
|
|
54
|
-
/** Drive the judge for one transcript and parse the result. */
|
|
55
|
-
export declare function gradeTranscript(adapter: HarnessAdapter, judge: ModelRef, prompt: string, cwd: string, expectedCriteria
|
|
64
|
+
/** Drive the judge for one transcript and parse the JSON-only result. */
|
|
65
|
+
export declare function gradeTranscript(adapter: HarnessAdapter, judge: ModelRef, prompt: string, cwd: string, expectedCriteria: number): Promise<GradeResult>;
|
|
56
66
|
/**
|
|
57
67
|
* Grade a transcript in a fresh, isolated, throwaway workspace — never the
|
|
58
68
|
* subject's scenario dir — so the judge can't ingest repo context the subject
|
|
59
69
|
* left behind (matters for provider CLIs that read cwd, including Pi).
|
|
60
70
|
*/
|
|
61
|
-
export declare function judgeInWorkspace(adapter: HarnessAdapter, judge: ModelRef, prompt: string, specDir: string, expectedCriteria
|
|
71
|
+
export declare function judgeInWorkspace(adapter: HarnessAdapter, judge: ModelRef, prompt: string, specDir: string, expectedCriteria: number): Promise<GradeResult>;
|
package/dist/grade.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createWorkspace } from "./workspace.js";
|
|
2
|
-
import {
|
|
2
|
+
import { completeCriterionVotes } from "./results.js";
|
|
3
3
|
/** The heading runSeeded writes above the staged diff. Gating on this exact string is what keeps the guidance honest — see below. */
|
|
4
4
|
export const STAGED_DIFF_HEADING = "=== STAGED DIFF ===";
|
|
5
5
|
/**
|
|
@@ -37,9 +37,8 @@ ${numbered}
|
|
|
37
37
|
TRANSCRIPT (the assistant is the model under test):
|
|
38
38
|
${transcript}
|
|
39
39
|
${diffGuidance}
|
|
40
|
-
Grade each checklist item PASS or FAIL with a <=12-word justification quoting the transcript. Be skeptical: if an item is not clearly satisfied, mark it FAIL.
|
|
41
|
-
|
|
42
|
-
REASON: <15 words or fewer>`;
|
|
40
|
+
Grade each checklist item PASS or FAIL with a <=12-word justification quoting the transcript. Be skeptical: if an item is not clearly satisfied, mark it FAIL. Reply with exactly one JSON object and nothing else, with one vote per criterion in checklist order:
|
|
41
|
+
{"votes":[{"criterion":1,"vote":"PASS"|"FAIL","reason":"..."},...],"verdict":"PASS"|"FAIL","reason":"..."}`;
|
|
43
42
|
}
|
|
44
43
|
// Both anchor to the start of a line and REQUIRE the colon. Without those anchors the
|
|
45
44
|
// reason pattern matched any word containing "reason" — git-ops GLM C1 stored
|
|
@@ -87,6 +86,85 @@ export function judgeResemblesSubject(judge, subject) {
|
|
|
87
86
|
const b = subject.model;
|
|
88
87
|
return a === b || a.includes(b) || b.includes(a);
|
|
89
88
|
}
|
|
89
|
+
/** Extract the first complete top-level JSON object, tolerating prose or fences around it. */
|
|
90
|
+
function extractFirstJsonObject(raw) {
|
|
91
|
+
const start = raw.indexOf("{");
|
|
92
|
+
if (start < 0)
|
|
93
|
+
throw new Error("no JSON object in judge reply");
|
|
94
|
+
let depth = 0;
|
|
95
|
+
let inString = false;
|
|
96
|
+
let escaped = false;
|
|
97
|
+
for (let i = start; i < raw.length; i++) {
|
|
98
|
+
const ch = raw[i];
|
|
99
|
+
if (inString) {
|
|
100
|
+
if (escaped)
|
|
101
|
+
escaped = false;
|
|
102
|
+
else if (ch === "\\")
|
|
103
|
+
escaped = true;
|
|
104
|
+
else if (ch === '"')
|
|
105
|
+
inString = false;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (ch === '"')
|
|
109
|
+
inString = true;
|
|
110
|
+
else if (ch === "{")
|
|
111
|
+
depth++;
|
|
112
|
+
else if (ch === "}" && --depth === 0)
|
|
113
|
+
return raw.slice(start, i + 1);
|
|
114
|
+
}
|
|
115
|
+
throw new Error("incomplete JSON object in judge reply");
|
|
116
|
+
}
|
|
117
|
+
function exactKeys(value, expected, context) {
|
|
118
|
+
const actual = Object.keys(value).sort();
|
|
119
|
+
const wanted = [...expected].sort();
|
|
120
|
+
if (JSON.stringify(actual) !== JSON.stringify(wanted)) {
|
|
121
|
+
throw new Error(`${context} must contain exactly ${wanted.join(", ")}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** Parse and validate the JSON-only contract used for new judge calls. */
|
|
125
|
+
export function parseStructuredJudgeReply(raw, expectedCriteria) {
|
|
126
|
+
let value;
|
|
127
|
+
try {
|
|
128
|
+
value = JSON.parse(extractFirstJsonObject(raw));
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
if (error instanceof Error && /JSON object in judge reply/.test(error.message))
|
|
132
|
+
throw error;
|
|
133
|
+
throw new Error(`invalid JSON: ${error.message}`);
|
|
134
|
+
}
|
|
135
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
136
|
+
throw new Error("judge reply must be a JSON object");
|
|
137
|
+
const object = value;
|
|
138
|
+
exactKeys(object, ["votes", "verdict", "reason"], "judge reply");
|
|
139
|
+
if (!Array.isArray(object.votes))
|
|
140
|
+
throw new Error("votes must be an array");
|
|
141
|
+
if (object.votes.length !== expectedCriteria)
|
|
142
|
+
throw new Error(`expected ${expectedCriteria} votes, got ${object.votes.length}`);
|
|
143
|
+
const criteria = object.votes.map((rawVote, offset) => {
|
|
144
|
+
if (!rawVote || typeof rawVote !== "object" || Array.isArray(rawVote))
|
|
145
|
+
throw new Error(`vote ${offset + 1} must be an object`);
|
|
146
|
+
const vote = rawVote;
|
|
147
|
+
exactKeys(vote, ["criterion", "vote", "reason"], `vote ${offset + 1}`);
|
|
148
|
+
if (vote.criterion !== offset + 1)
|
|
149
|
+
throw new Error(`vote ${offset + 1} must have criterion ${offset + 1}`);
|
|
150
|
+
if (vote.vote !== "PASS" && vote.vote !== "FAIL")
|
|
151
|
+
throw new Error(`vote ${offset + 1} must be PASS or FAIL`);
|
|
152
|
+
if (typeof vote.reason !== "string")
|
|
153
|
+
throw new Error(`vote ${offset + 1} reason must be a string`);
|
|
154
|
+
return { index: offset + 1, verdict: vote.vote, reason: vote.reason };
|
|
155
|
+
});
|
|
156
|
+
if (object.verdict !== "PASS" && object.verdict !== "FAIL")
|
|
157
|
+
throw new Error("verdict must be PASS or FAIL");
|
|
158
|
+
if (typeof object.reason !== "string")
|
|
159
|
+
throw new Error("reason must be a string");
|
|
160
|
+
return { criteria, verdict: object.verdict, reason: object.reason };
|
|
161
|
+
}
|
|
162
|
+
/** Preserve one raw reply byte-for-byte, or both retry attempts with explicit boundaries. */
|
|
163
|
+
export function formatJudgeRawReplies(replies) {
|
|
164
|
+
if (replies.length === 1)
|
|
165
|
+
return replies[0];
|
|
166
|
+
return replies.map((reply, index) => `=== JUDGE REPLY ${index + 1} ===\n${reply}`).join("\n\n");
|
|
167
|
+
}
|
|
90
168
|
const ITEM_RE = /^\s*\d+[.)]\s*\**\s*(PASS|FAIL)\b/gim;
|
|
91
169
|
/**
|
|
92
170
|
* Judge-misfire detector: parse the judge's per-checklist-item grades and assert
|
|
@@ -122,27 +200,48 @@ export function detectMisfire(raw, verdict) {
|
|
|
122
200
|
}
|
|
123
201
|
// Non-global twin of REASON_RE: matchAll needs /g, a single .match() must not have it.
|
|
124
202
|
const REASON_LINE_RE = /^\s*\**\s*REASON\**\s*:\s*\**\s*(.*)$/im;
|
|
125
|
-
/** Drive the judge for one transcript and parse the result. */
|
|
203
|
+
/** Drive the judge for one transcript and parse the JSON-only result. */
|
|
126
204
|
export async function gradeTranscript(adapter, judge, prompt, cwd, expectedCriteria) {
|
|
127
|
-
const
|
|
128
|
-
let
|
|
129
|
-
let
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
205
|
+
const rawReplies = [];
|
|
206
|
+
let validationError = "";
|
|
207
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
208
|
+
const retry = attempt === 0 ? "" : `\n\nYour reply was not valid: ${JSON.stringify(validationError)}; reply with only the JSON object.`;
|
|
209
|
+
const raw = await adapter.judge({ model: judge, prompt: prompt + retry, cwd });
|
|
210
|
+
rawReplies.push(raw);
|
|
211
|
+
// Pi adapter failures are infrastructure errors, not malformed model votes.
|
|
212
|
+
if (/^\[judge error:/i.test(raw.trim())) {
|
|
213
|
+
const snippet = raw.trim().replace(/\s+/g, " ").slice(0, 160);
|
|
214
|
+
return {
|
|
215
|
+
verdict: "ERROR", reason: `judge unparseable: ${snippet}`, suspect: false,
|
|
216
|
+
raw, rawReplies, criteria: completeCriterionVotes([], expectedCriteria), judgeFormat: "json",
|
|
217
|
+
...(attempt === 1 ? { judgeRetries: 1 } : {}),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
const parsed = parseStructuredJudgeReply(raw, expectedCriteria);
|
|
222
|
+
const suspect = parsed.verdict === "FAIL"
|
|
223
|
+
? parsed.criteria.every(vote => vote.verdict === "PASS")
|
|
224
|
+
: parsed.criteria.some(vote => vote.verdict === "FAIL");
|
|
225
|
+
return {
|
|
226
|
+
...parsed, raw, rawReplies, suspect, judgeFormat: "json",
|
|
227
|
+
...(attempt === 1 ? { judgeRetries: 1 } : {}),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
validationError = error.message;
|
|
232
|
+
}
|
|
143
233
|
}
|
|
144
|
-
const
|
|
145
|
-
return {
|
|
234
|
+
const raw = rawReplies[rawReplies.length - 1];
|
|
235
|
+
return {
|
|
236
|
+
verdict: "UNGRADED",
|
|
237
|
+
reason: `judge structured reply invalid after retry: ${validationError}`,
|
|
238
|
+
suspect: false,
|
|
239
|
+
raw,
|
|
240
|
+
rawReplies,
|
|
241
|
+
criteria: completeCriterionVotes([], expectedCriteria),
|
|
242
|
+
judgeFormat: "json",
|
|
243
|
+
judgeRetries: 1,
|
|
244
|
+
};
|
|
146
245
|
}
|
|
147
246
|
/**
|
|
148
247
|
* Grade a transcript in a fresh, isolated, throwaway workspace — never the
|
|
@@ -31,6 +31,10 @@
|
|
|
31
31
|
* convention cannot drift apart.
|
|
32
32
|
*/
|
|
33
33
|
export declare const PROVIDER_FAILURE_MARKER = "[skill-harness] provider failure:";
|
|
34
|
+
/** Failed or unproven terminal delivery, without assuming a provider-side cause. */
|
|
35
|
+
export declare const EXECUTION_FAILURE_MARKER = "[skill-harness] execution failure:";
|
|
36
|
+
export declare function withExecutionFailure(transcript: string, failure: string | null): string;
|
|
37
|
+
export declare function executionFailureFromTranscript(transcript: string): string | null;
|
|
34
38
|
/**
|
|
35
39
|
* Prefix `transcript` with the provider-failure marker, or return it unchanged.
|
|
36
40
|
*
|
package/dist/provider-failure.js
CHANGED
|
@@ -31,6 +31,14 @@
|
|
|
31
31
|
* convention cannot drift apart.
|
|
32
32
|
*/
|
|
33
33
|
export const PROVIDER_FAILURE_MARKER = "[skill-harness] provider failure:";
|
|
34
|
+
/** Failed or unproven terminal delivery, without assuming a provider-side cause. */
|
|
35
|
+
export const EXECUTION_FAILURE_MARKER = "[skill-harness] execution failure:";
|
|
36
|
+
export function withExecutionFailure(transcript, failure) {
|
|
37
|
+
return failure ? `${EXECUTION_FAILURE_MARKER} ${failure}\n\n${transcript}` : transcript;
|
|
38
|
+
}
|
|
39
|
+
export function executionFailureFromTranscript(transcript) {
|
|
40
|
+
return failureFromPreamble(transcript, EXECUTION_FAILURE_MARKER);
|
|
41
|
+
}
|
|
34
42
|
/**
|
|
35
43
|
* The adapters' shared turn-header prefix (`>>> USER:` / `>>> USER (turn n/m):`),
|
|
36
44
|
* which is where the preamble ends and model-influenced content begins.
|
|
@@ -87,11 +95,14 @@ export function providerFailureFromJsonLine(line) {
|
|
|
87
95
|
* provider failed — it is evidence the model typed the words.
|
|
88
96
|
*/
|
|
89
97
|
export function providerFailureFromTranscript(transcript) {
|
|
98
|
+
return failureFromPreamble(transcript, PROVIDER_FAILURE_MARKER);
|
|
99
|
+
}
|
|
100
|
+
function failureFromPreamble(transcript, marker) {
|
|
90
101
|
for (const line of transcript.split("\n")) {
|
|
91
102
|
if (line.startsWith(TURN_HEADER_PREFIX))
|
|
92
103
|
return null;
|
|
93
|
-
if (line.startsWith(
|
|
94
|
-
return line.slice(
|
|
104
|
+
if (line.startsWith(marker))
|
|
105
|
+
return line.slice(marker.length).trim();
|
|
95
106
|
}
|
|
96
107
|
return null;
|
|
97
108
|
}
|
package/dist/regate.js
CHANGED
|
@@ -49,11 +49,19 @@ function rewriteTranscript(path, gateLines) {
|
|
|
49
49
|
renameSync(path, path.replace(/\.txt$/, ".pre-regate.txt"));
|
|
50
50
|
writeFileSync(path, `${head}${TRAILER}\n${gateLines.join("\n")}\n\n${tail}`, "utf8");
|
|
51
51
|
}
|
|
52
|
-
/** Recover a rep's judge verdict from
|
|
53
|
-
function verdictFromSavedJudgement(runDir, id, mode, rep) {
|
|
52
|
+
/** Recover a rep's judge verdict from retained structured evidence or its legacy prose artifact. */
|
|
53
|
+
function verdictFromSavedJudgement(runDir, id, mode, rep, retained) {
|
|
54
54
|
const path = judgeRawPath(runDir, id, mode, rep);
|
|
55
55
|
if (!existsSync(path))
|
|
56
56
|
return null;
|
|
57
|
+
if (retained?.judgeFormat === "json") {
|
|
58
|
+
return {
|
|
59
|
+
verdict: retained.verdict,
|
|
60
|
+
reason: retained.reason,
|
|
61
|
+
suspect: retained.suspect,
|
|
62
|
+
judgment: retained,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
57
65
|
const raw = readFileSync(path, "utf8");
|
|
58
66
|
const parsed = parseVerdict(raw);
|
|
59
67
|
return { verdict: parsed.verdict, reason: parsed.reason, suspect: detectMisfire(raw, parsed.verdict) };
|
|
@@ -284,7 +292,9 @@ export async function regateRun(opts) {
|
|
|
284
292
|
if (!oldGateFailed) {
|
|
285
293
|
// The judge already saw this rep. Its verdict is on disk — re-read it rather
|
|
286
294
|
// than paying to ask the same question again.
|
|
287
|
-
const
|
|
295
|
+
const panel = rec.rep_judgments?.find(candidate => candidate.repetition === (rep ?? 0));
|
|
296
|
+
const retained = panel?.judgments.find(judgment => judgment.ordinal === 1) ?? panel?.judgments[0];
|
|
297
|
+
const saved = verdictFromSavedJudgement(opts.runDir, scenario.id, mode, rep, retained);
|
|
288
298
|
outcomes.push({ ...(saved ?? { verdict: rec.judge_verdict, reason: rec.judge_reason, suspect: rec.suspect }), objective });
|
|
289
299
|
continue;
|
|
290
300
|
}
|
package/dist/regrade.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { buildJudgePrompt, judgeInWorkspace } from "./grade.js";
|
|
3
|
+
import { buildJudgePrompt, formatJudgeRawReplies, judgeInWorkspace } from "./grade.js";
|
|
4
4
|
import { findTranscriptFiles, judgeRawPath, repIndexOf, readResults, writeResults, effectiveThreshold, scoreContextFor, rebuildScenarioResult, mergeScenarioMetrics, carryRepObjectives, completeCriterionVotes, } from "./results.js";
|
|
5
5
|
import { outcomesToResult, normalizeRepOutcome } from "./reps.js";
|
|
6
6
|
import { appendJournal } from "./journal.js";
|
|
7
7
|
import { rubricDigest, personaDigest, RUBRIC_PREFIX, PERSONA_KEY } from "./sources.js";
|
|
8
|
-
import { providerFailureFromTranscript } from "./provider-failure.js";
|
|
8
|
+
import { providerFailureFromTranscript, executionFailureFromTranscript } from "./provider-failure.js";
|
|
9
9
|
/**
|
|
10
10
|
* Carry a run's recorded hashes forward, refreshing only the `rubric:` keys this
|
|
11
11
|
* re-grade actually judged under (plus the persona, which applies to all of them).
|
|
@@ -47,8 +47,9 @@ export async function judgeOneRep(opts) {
|
|
|
47
47
|
// into a model verdict, spending a judge call to turn infrastructure noise into
|
|
48
48
|
// a false FAIL.
|
|
49
49
|
const providerFailure = providerFailureFromTranscript(transcript);
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
const executionFailure = executionFailureFromTranscript(transcript);
|
|
51
|
+
if (providerFailure || executionFailure) {
|
|
52
|
+
const reason = providerFailure ? `provider failure — ${providerFailure}` : `execution failure — ${executionFailure}`;
|
|
52
53
|
appendJournal(runDir, { event: "judge-verdict", ts: now(), id: scenario.id, verdict: "ERROR", reason, suspect: false, ...repField });
|
|
53
54
|
return {
|
|
54
55
|
verdict: "ERROR", reason, suspect: false,
|
|
@@ -57,10 +58,10 @@ export async function judgeOneRep(opts) {
|
|
|
57
58
|
}
|
|
58
59
|
const prompt = buildJudgePrompt({ skill: spec.skill, persona: spec.judge_persona, scenario, transcript });
|
|
59
60
|
const g = await judgeInWorkspace(adapter, judge, prompt, specDir, scenario.checklist.length);
|
|
60
|
-
writeFileSync(judgeRawPath(runDir, scenario.id, mode, rep), g.
|
|
61
|
+
writeFileSync(judgeRawPath(runDir, scenario.id, mode, rep), formatJudgeRawReplies(g.rawReplies), "utf8");
|
|
61
62
|
const outcome = normalizeRepOutcome({
|
|
62
63
|
verdict: g.verdict, reason: g.reason, suspect: g.suspect, objective: opts.objective,
|
|
63
|
-
judgment: { ordinal: 1, judge: { ...judge }, verdict: g.verdict, reason: g.reason, suspect: g.suspect, criteria: completeCriterionVotes(g.criteria, scenario.checklist.length), ...(g.judgeRetries ? { judgeRetries: g.judgeRetries } : {}) },
|
|
64
|
+
judgment: { ordinal: 1, judge: { ...judge }, verdict: g.verdict, reason: g.reason, suspect: g.suspect, criteria: completeCriterionVotes(g.criteria, scenario.checklist.length), judgeFormat: g.judgeFormat, ...(g.judgeRetries ? { judgeRetries: g.judgeRetries } : {}) },
|
|
64
65
|
metrics: {
|
|
65
66
|
wall_time_ms: Math.max(0, Math.round(performance.now() - startedAt)),
|
|
66
67
|
judge_calls: 1 + (g.judgeRetries ?? 0),
|
package/dist/results.d.ts
CHANGED
|
@@ -101,7 +101,9 @@ export interface Judgment {
|
|
|
101
101
|
suspect: boolean;
|
|
102
102
|
/** Every numbered rubric-item vote parsed from this exact judge response. Required in schema v3. */
|
|
103
103
|
criteria?: CriterionVote[];
|
|
104
|
-
/**
|
|
104
|
+
/** Structured format for new judgments; prose is retained compatibility evidence. */
|
|
105
|
+
judgeFormat?: "json" | "prose";
|
|
106
|
+
/** One validation-guided retry after an invalid structured reply. */
|
|
105
107
|
judgeRetries?: 1;
|
|
106
108
|
}
|
|
107
109
|
export interface AdjudicationResult {
|
package/dist/run.js
CHANGED
|
@@ -16,7 +16,7 @@ import { judgeOneRep } from "./regrade.js";
|
|
|
16
16
|
import { runDeliveryCanary, canaryFailure } from "./canary.js";
|
|
17
17
|
import { boundaryCells, stabilityNote } from "./stability.js";
|
|
18
18
|
import { aggregateMetrics } from "./metrics.js";
|
|
19
|
-
import { providerFailureFromTranscript } from "./provider-failure.js";
|
|
19
|
+
import { providerFailureFromTranscript, executionFailureFromTranscript } from "./provider-failure.js";
|
|
20
20
|
/**
|
|
21
21
|
* The pi-daddy arm's delivery proof, by convention: `arms.yaml` points
|
|
22
22
|
* `PI_GRANTS_LEDGER` at exactly this filename inside the run directory. A fixed
|
|
@@ -303,6 +303,7 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
303
303
|
// and the artifact disagreeing, and a recovered measurement thrown away.
|
|
304
304
|
infrastructureFailure = null;
|
|
305
305
|
adapterFailure = null;
|
|
306
|
+
let executionUnavailable = false;
|
|
306
307
|
try {
|
|
307
308
|
if (scenario.mode === "seeded") {
|
|
308
309
|
const r = await runSeeded(scenario, {
|
|
@@ -348,8 +349,11 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
348
349
|
const structured = await ctx.adapter.runStructured({ ...req, scenarioId: scenario.id, rep });
|
|
349
350
|
transcript = structured.transcript;
|
|
350
351
|
traces = structured.traces;
|
|
352
|
+
executionUnavailable = Boolean(structured.executionFailure);
|
|
351
353
|
if (structured.providerFailure)
|
|
352
354
|
infrastructureFailure = `provider failure — ${structured.providerFailure}`;
|
|
355
|
+
else if (structured.executionFailure)
|
|
356
|
+
infrastructureFailure = `execution failure — ${structured.executionFailure}`;
|
|
353
357
|
}
|
|
354
358
|
else {
|
|
355
359
|
transcript = await ctx.adapter.run(req);
|
|
@@ -384,9 +388,19 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
384
388
|
const provider = providerFailureFromTranscript(transcript);
|
|
385
389
|
if (provider)
|
|
386
390
|
infrastructureFailure = `provider failure — ${provider}`;
|
|
391
|
+
else {
|
|
392
|
+
const execution = executionFailureFromTranscript(transcript);
|
|
393
|
+
if (execution)
|
|
394
|
+
infrastructureFailure = `execution failure — ${execution}`;
|
|
395
|
+
}
|
|
387
396
|
}
|
|
388
|
-
|
|
389
|
-
if
|
|
397
|
+
executionUnavailable ||= executionFailureFromTranscript(transcript) !== null;
|
|
398
|
+
// Qualified complete traces prove delivered bytes even if those bytes
|
|
399
|
+
// are only whitespace; the legacy text-only timeout heuristic cannot erase them.
|
|
400
|
+
const deliveredText = traces.length > 0 && traces.every((trace) => trace.final_status === "complete" && trace.final_text.length > 0 && !trace.capture_errors?.length);
|
|
401
|
+
noResponse = !deliveredText && hasEmptyAssistantTurn(transcript);
|
|
402
|
+
// Known unavailable execution is not an empty model answer to retry.
|
|
403
|
+
if (executionUnavailable || (!noResponse && !adapterFailure))
|
|
390
404
|
break;
|
|
391
405
|
}
|
|
392
406
|
// Survived the retry: ERROR for this rep, inside a run that still completes
|
|
@@ -578,7 +592,7 @@ export function formatScorecard(summary, lift, stability) {
|
|
|
578
592
|
const tokens = metrics.input_tokens === null
|
|
579
593
|
? "subject tokens unavailable"
|
|
580
594
|
: `subject tokens ${metrics.input_tokens} in / ${metrics.output_tokens ?? 0} out / ${metrics.cache_read_tokens ?? 0} cache-read`;
|
|
581
|
-
const tools = metrics.tool_calls === null ? "tool calls unavailable" : `${metrics.tool_calls} tool call(s), ${metrics.delegated_children ?? 0}
|
|
595
|
+
const tools = metrics.tool_calls === null ? "tool calls unavailable" : `${metrics.tool_calls} tool call(s), ${metrics.delegated_children ?? 0} requested Agent task(s), max outstanding tool calls ${metrics.max_concurrency ?? 0}; child launches/concurrency unobserved`;
|
|
582
596
|
const cost = metrics.cost_source === "subscription"
|
|
583
597
|
? " · subscription ($0 marginal cost recorded)"
|
|
584
598
|
: metrics.cost_source === "unreported"
|
package/package.json
CHANGED