@skill-harness/core 0.23.0 → 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.
@@ -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?(): Promise<string | null>;
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;
@@ -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" || SKIPPED.has(type))
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
- continue; // an end with no start is not evidence of a call
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
- const at = isoTime(msg?.timestamp);
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("\n")
198
- .trim();
279
+ .join("");
199
280
  }
200
281
  /**
201
282
  * Bounded metadata about a tool result. The body is never kept: results carry
@@ -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
  *
@@ -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(PROVIDER_FAILURE_MARKER))
94
- return line.slice(PROVIDER_FAILURE_MARKER.length).trim();
104
+ if (line.startsWith(marker))
105
+ return line.slice(marker.length).trim();
95
106
  }
96
107
  return null;
97
108
  }
package/dist/regrade.js CHANGED
@@ -5,7 +5,7 @@ import { findTranscriptFiles, judgeRawPath, repIndexOf, readResults, writeResult
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
- if (providerFailure) {
51
- const reason = `provider failure — ${providerFailure}`;
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,
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
- noResponse = hasEmptyAssistantTurn(transcript);
389
- if (!noResponse && !adapterFailure)
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} delegated, max concurrency ${metrics.max_concurrency ?? 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-harness/core",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "skill-harness engine — spec, discover, run, LLM-judge grade, score, results (internal API)",
5
5
  "type": "module",
6
6
  "license": "MIT",