@skill-harness/adapters 0.10.0 → 0.11.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/pi-json.d.ts CHANGED
@@ -32,6 +32,11 @@ export interface PiJsonRunOptions {
32
32
  turn: number;
33
33
  changedPaths?: string[];
34
34
  homeDir?: string;
35
+ /**
36
+ * Extra env for the subject process, from the arm. `spawn` treats `undefined`
37
+ * as "inherit", so the control arm (which never sets this) is unaffected.
38
+ */
39
+ env?: NodeJS.ProcessEnv;
35
40
  }
36
41
  export interface PiJsonRunResult {
37
42
  trace: ExecutionTraceV1;
@@ -39,5 +44,12 @@ export interface PiJsonRunResult {
39
44
  malformedLines: number;
40
45
  code: number | null;
41
46
  stderr: string;
47
+ /**
48
+ * Set when a line on the stream carried a provider-side failure diagnostic
49
+ * (auth, transport) rather than the model answering badly. `runStructured`
50
+ * collects this across turns; `run.ts` turns it into ERROR — never a model
51
+ * verdict.
52
+ */
53
+ providerFailure: string | null;
42
54
  }
43
55
  export declare function runPiJson(opts: PiJsonRunOptions): Promise<PiJsonRunResult>;
package/dist/pi-json.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createInterface } from "node:readline";
3
- import { parseTrace } from "@skill-harness/core";
3
+ import { parseTrace, providerFailureFromJsonLine } from "@skill-harness/core";
4
4
  /**
5
5
  * Run `pi --mode json` and build an execution trace, **streaming**.
6
6
  *
@@ -28,12 +28,14 @@ export function runPiJson(opts) {
28
28
  return new Promise((resolve, reject) => {
29
29
  const child = spawn("pi", opts.args, {
30
30
  cwd: opts.cwd,
31
+ env: opts.env,
31
32
  // stdin from /dev/null: pi hangs waiting on it otherwise, and a hang in a
32
33
  // wave is indistinguishable from a slow model until the timeout fires.
33
34
  stdio: ["ignore", "pipe", "pipe"],
34
35
  });
35
36
  const kept = [];
36
37
  let stderr = "";
38
+ let providerFailure = null;
37
39
  let settled = false;
38
40
  const timer = setTimeout(() => {
39
41
  if (settled)
@@ -58,6 +60,8 @@ export function runPiJson(opts) {
58
60
  if (SKIPPED_TYPE_RE.test(line))
59
61
  return;
60
62
  kept.push(line);
63
+ if (providerFailure === null)
64
+ providerFailure = providerFailureFromJsonLine(line);
61
65
  });
62
66
  child.stderr.on("data", (chunk) => {
63
67
  if (stderr.length < MAX_STDERR_CHARS)
@@ -85,7 +89,7 @@ export function runPiJson(opts) {
85
89
  changedPaths: opts.changedPaths,
86
90
  homeDir: opts.homeDir,
87
91
  });
88
- resolve({ ...parsed, code, stderr: stderr.slice(0, MAX_STDERR_CHARS) });
92
+ resolve({ ...parsed, code, stderr: stderr.slice(0, MAX_STDERR_CHARS), providerFailure });
89
93
  });
90
94
  });
91
95
  }
package/dist/pi.js CHANGED
@@ -3,8 +3,24 @@ import { tmpdir, homedir } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
4
  import { runPiJson } from "./pi-json.js";
5
5
  import { collectTrajectorySources, normalizePiTraces, resequence } from "./trajectory.js";
6
- import { exec, onPath, envNum, traceSha256 } from "@skill-harness/core";
6
+ import { exec, onPath, envNum, traceSha256, withProviderFailure } from "@skill-harness/core";
7
7
  const PI_TIMEOUT_MS = envNum("PI_TIMEOUT_MS", 300_000);
8
+ /**
9
+ * stderr fragments that mean the provider refused the request, so the run measured
10
+ * nothing about the model. Substring matching on a message pi passes through from
11
+ * the provider — deliberately narrow: a stderr line we cannot classify stays an
12
+ * ordinary non-zero exit, because calling a real model failure "infrastructure"
13
+ * would hide a regression.
14
+ */
15
+ const PROVIDER_STDERR_SIGNATURES = [
16
+ "invalidated oauth token",
17
+ "invalid_api_key",
18
+ "insufficient_quota",
19
+ ];
20
+ function providerStderr(stderr) {
21
+ const hay = stderr.toLowerCase();
22
+ return PROVIDER_STDERR_SIGNATURES.some((sig) => hay.includes(sig)) ? stderr.trim() : null;
23
+ }
8
24
  /**
9
25
  * Refuse to hand pi a skill dir it will silently ignore.
10
26
  *
@@ -127,26 +143,46 @@ export const piAdapter = {
127
143
  : skillFlags(req.mode, req.skillDir);
128
144
  const total = req.turns.length;
129
145
  const parts = [];
146
+ // The arm's env, merged over the harness's own — undefined (not `process.env`)
147
+ // when there is none, so `exec`'s `env: opts.env ?? process.env` inherits
148
+ // normally and the control arm is unaffected.
149
+ const env = req.armEnv ? { ...process.env, ...req.armEnv } : undefined;
150
+ // Collected across turns and written by `withProviderFailure` into the
151
+ // transcript PREAMBLE at the end, never inline after an assistant turn: the
152
+ // preamble is the only region of the transcript the model provably cannot
153
+ // reach, and a marker anywhere else is forgeable by a model that types the
154
+ // words (which would convert a FAIL into ERROR and mute the judge forever).
155
+ let providerFailure = null;
130
156
  if (total === 1) {
131
157
  const args = [...flags, ...common, "--no-session", "-p", req.turns[0]];
132
- const r = await exec("pi", args, { cwd: req.cwd, timeoutMs: PI_TIMEOUT_MS });
158
+ const r = await exec("pi", args, { cwd: req.cwd, timeoutMs: PI_TIMEOUT_MS, env });
133
159
  parts.push(header(1, 1, req.turns[0]));
134
160
  parts.push(`<<< ASSISTANT:\n${r.stdout.trim()}\n`);
135
- if (r.code !== 0)
136
- parts.push(`[pi exited ${r.code}]\n${r.stderr.trim()}\n`);
137
- return parts.join("\n");
161
+ if (r.code !== 0) {
162
+ providerFailure = providerStderr(r.stderr);
163
+ if (!providerFailure)
164
+ parts.push(`[pi exited ${r.code}]\n${r.stderr.trim()}\n`);
165
+ }
166
+ return withProviderFailure(parts.join("\n"), providerFailure);
138
167
  }
139
168
  const session = mkdtempSync(join(tmpdir(), "sc-pi-session-"));
140
169
  for (let i = 0; i < total; i++) {
141
170
  const turnFlags = i === 0 ? ["--session-dir", session] : ["--session-dir", session, "-c"];
142
171
  const args = [...flags, ...common, ...turnFlags, "-p", req.turns[i]];
143
- const r = await exec("pi", args, { cwd: req.cwd, timeoutMs: PI_TIMEOUT_MS });
172
+ const r = await exec("pi", args, { cwd: req.cwd, timeoutMs: PI_TIMEOUT_MS, env });
144
173
  parts.push(header(i + 1, total, req.turns[i]));
145
174
  parts.push(`<<< ASSISTANT:\n${r.stdout.trim()}\n`);
146
- if (r.code !== 0)
147
- parts.push(`[pi exited ${r.code} on turn ${i + 1}]\n${r.stderr.trim()}\n`);
175
+ if (r.code !== 0) {
176
+ const provider = providerStderr(r.stderr);
177
+ // First failure wins, same as the structured path: the turns after an
178
+ // outage are downstream of it, not independent evidence.
179
+ if (provider && providerFailure === null)
180
+ providerFailure = provider;
181
+ if (!provider)
182
+ parts.push(`[pi exited ${r.code} on turn ${i + 1}]\n${r.stderr.trim()}\n`);
183
+ }
148
184
  }
149
- return parts.join("\n");
185
+ return withProviderFailure(parts.join("\n"), providerFailure);
150
186
  },
151
187
  /**
152
188
  * Structured run: same flags, same turn loop, plus `--mode json` and a trace
@@ -178,6 +214,10 @@ export const piAdapter = {
178
214
  const traces = [];
179
215
  const parts = [];
180
216
  const session = total === 1 ? null : mkdtempSync(join(tmpdir(), "sc-pi-session-"));
217
+ let providerFailure = null;
218
+ // Same merge as `run()`: undefined when the arm carries no env, so `spawn`
219
+ // (which treats `undefined` as "inherit") leaves the control arm untouched.
220
+ const env = req.armEnv ? { ...process.env, ...req.armEnv } : undefined;
181
221
  for (let i = 0; i < total; i++) {
182
222
  const turnFlags = session === null
183
223
  ? ["--no-session"]
@@ -196,6 +236,7 @@ export const piAdapter = {
196
236
  rep: req.rep ?? 0,
197
237
  turn: i,
198
238
  homeDir: homedir(),
239
+ env,
199
240
  });
200
241
  // A stream with no terminal events at all is not evidence of a clean run.
201
242
  // Fail loudly here rather than let an empty trace satisfy a `forbid_calls`
@@ -210,6 +251,14 @@ export const piAdapter = {
210
251
  r.trace.capture_errors = [`pi JSONL contained ${r.malformedLines} malformed line(s); absence-based trace assertions are unsafe`];
211
252
  r.trace.trace_sha256 = traceSha256(r.trace);
212
253
  }
254
+ // Recorded here, written into the transcript preamble by
255
+ // `withProviderFailure` below — not just returned on `providerFailure`: the
256
+ // artifact on disk is the only thing a later `grade`/`regrade` call ever
257
+ // reads (see `judgeOneRep` in core/regrade.ts), and the structured path
258
+ // exits 0 while carrying the evidence, so a field a re-judge never sees
259
+ // leaves it unrecoverable from the saved transcript.
260
+ if (providerFailure === null && r.providerFailure)
261
+ providerFailure = r.providerFailure;
213
262
  traces.push(r.trace);
214
263
  parts.push(header(i + 1, total, req.turns[i]));
215
264
  parts.push(`<<< ASSISTANT:\n${r.trace.final_text.trim()}\n`);
@@ -235,10 +284,11 @@ export const piAdapter = {
235
284
  }
236
285
  const eventErrors = [...native.errors, ...chronologyErrors];
237
286
  return {
238
- transcript: parts.join("\n"),
287
+ transcript: withProviderFailure(parts.join("\n"), providerFailure),
239
288
  traces,
240
289
  events: resequence(combined),
241
290
  ...(eventErrors.length ? { eventErrors } : {}),
291
+ ...(providerFailure ? { providerFailure } : {}),
242
292
  };
243
293
  },
244
294
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-harness/adapters",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "skill-harness harness adapters — pi runner + claude-code judge routing (internal API)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -40,6 +40,6 @@
40
40
  "prepack": "cp ../../LICENSE ./LICENSE"
41
41
  },
42
42
  "dependencies": {
43
- "@skill-harness/core": "0.10.0"
43
+ "@skill-harness/core": "0.11.0"
44
44
  }
45
45
  }