@skill-harness/adapters 0.5.0 → 0.7.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 +22 -0
- package/dist/pi-json.js +92 -0
- package/dist/pi.js +91 -1
- package/package.json +2 -2
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ExecutionTraceV1, ModelRef, RunMode } from "@skill-harness/core";
|
|
2
|
+
export interface PiJsonRunOptions {
|
|
3
|
+
args: string[];
|
|
4
|
+
cwd: string;
|
|
5
|
+
timeoutMs: number;
|
|
6
|
+
piVersion: string | null;
|
|
7
|
+
subject: ModelRef;
|
|
8
|
+
scenarioId: string;
|
|
9
|
+
mode: RunMode;
|
|
10
|
+
rep: number;
|
|
11
|
+
turn: number;
|
|
12
|
+
changedPaths?: string[];
|
|
13
|
+
homeDir?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface PiJsonRunResult {
|
|
16
|
+
trace: ExecutionTraceV1;
|
|
17
|
+
isComplete: boolean;
|
|
18
|
+
malformedLines: number;
|
|
19
|
+
code: number | null;
|
|
20
|
+
stderr: string;
|
|
21
|
+
}
|
|
22
|
+
export declare function runPiJson(opts: PiJsonRunOptions): Promise<PiJsonRunResult>;
|
package/dist/pi-json.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import { parseTrace } from "@skill-harness/core";
|
|
4
|
+
/**
|
|
5
|
+
* Run `pi --mode json` and build an execution trace, **streaming**.
|
|
6
|
+
*
|
|
7
|
+
* The streaming is not an optimization, it is the requirement. pi's
|
|
8
|
+
* `message_update` events re-send the entire accumulated message on every delta,
|
|
9
|
+
* so stdout is quadratic in the answer's length — a trivial three-tool-call run
|
|
10
|
+
* measured **52 MB** of stdout wrapping 12 KB of terminal events. Buffering that
|
|
11
|
+
* into a string (which is what the shared `exec()` helper does) would exhaust
|
|
12
|
+
* memory partway through a long wave, taking the whole run with it.
|
|
13
|
+
*
|
|
14
|
+
* So this deliberately does NOT reuse `exec()`. The two quadratic event types are
|
|
15
|
+
* dropped as each line arrives, so the giant ones are never retained; the
|
|
16
|
+
* remainder — a few KB of terminal events — is held until `close` and parsed
|
|
17
|
+
* once. What this bounds is the 52 MB, not the residue.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* The two quadratic event types, matched at the head of the object where pi
|
|
21
|
+
* emits `type`. Line-anchored so a value inside the payload cannot masquerade as
|
|
22
|
+
* the event kind.
|
|
23
|
+
*/
|
|
24
|
+
const SKIPPED_TYPE_RE = /^\s*\{\s*"type"\s*:\s*"(?:message_update|tool_execution_update)"/;
|
|
25
|
+
/** How much stderr to retain — enough to diagnose, bounded so a loop cannot blow up. */
|
|
26
|
+
const MAX_STDERR_CHARS = 8000;
|
|
27
|
+
export function runPiJson(opts) {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const child = spawn("pi", opts.args, {
|
|
30
|
+
cwd: opts.cwd,
|
|
31
|
+
// stdin from /dev/null: pi hangs waiting on it otherwise, and a hang in a
|
|
32
|
+
// wave is indistinguishable from a slow model until the timeout fires.
|
|
33
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
34
|
+
});
|
|
35
|
+
const kept = [];
|
|
36
|
+
let stderr = "";
|
|
37
|
+
let settled = false;
|
|
38
|
+
const timer = setTimeout(() => {
|
|
39
|
+
if (settled)
|
|
40
|
+
return;
|
|
41
|
+
settled = true;
|
|
42
|
+
child.kill("SIGKILL");
|
|
43
|
+
reject(new Error(`pi --mode json timed out after ${opts.timeoutMs}ms`));
|
|
44
|
+
}, opts.timeoutMs);
|
|
45
|
+
const rl = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
46
|
+
rl.on("line", (line) => {
|
|
47
|
+
// Prefilter before the full parse: the events skipped here are both the
|
|
48
|
+
// overwhelming majority of lines and by far the largest.
|
|
49
|
+
//
|
|
50
|
+
// Anchored on the `type` field, NOT a substring of the whole line. A raw
|
|
51
|
+
// `line.includes('"message_update"')` also matched any event whose
|
|
52
|
+
// ARGUMENTS contained that text — so a `tool_execution_start` for, say,
|
|
53
|
+
// `grep '"message_update"' logs/` was dropped before parsing, and a
|
|
54
|
+
// dropped start means the call never enters the trace at all. A
|
|
55
|
+
// `forbid_calls` gate on that tool then passed for want of the evidence.
|
|
56
|
+
if (!line.trim())
|
|
57
|
+
return;
|
|
58
|
+
if (SKIPPED_TYPE_RE.test(line))
|
|
59
|
+
return;
|
|
60
|
+
kept.push(line);
|
|
61
|
+
});
|
|
62
|
+
child.stderr.on("data", (chunk) => {
|
|
63
|
+
if (stderr.length < MAX_STDERR_CHARS)
|
|
64
|
+
stderr += chunk.toString("utf8");
|
|
65
|
+
});
|
|
66
|
+
child.on("error", (err) => {
|
|
67
|
+
if (settled)
|
|
68
|
+
return;
|
|
69
|
+
settled = true;
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
reject(err);
|
|
72
|
+
});
|
|
73
|
+
child.on("close", (code) => {
|
|
74
|
+
if (settled)
|
|
75
|
+
return;
|
|
76
|
+
settled = true;
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
const parsed = parseTrace(kept, {
|
|
79
|
+
piVersion: opts.piVersion,
|
|
80
|
+
subject: opts.subject,
|
|
81
|
+
scenarioId: opts.scenarioId,
|
|
82
|
+
mode: opts.mode,
|
|
83
|
+
rep: opts.rep,
|
|
84
|
+
turn: opts.turn,
|
|
85
|
+
changedPaths: opts.changedPaths,
|
|
86
|
+
homeDir: opts.homeDir,
|
|
87
|
+
});
|
|
88
|
+
resolve({ ...parsed, code, stderr: stderr.slice(0, MAX_STDERR_CHARS) });
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=pi-json.js.map
|
package/dist/pi.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdtempSync, readFileSync, statSync } from "node:fs";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
2
|
+
import { tmpdir, homedir } from "node:os";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
|
+
import { runPiJson } from "./pi-json.js";
|
|
4
5
|
import { exec, onPath, envNum } from "@skill-harness/core";
|
|
5
6
|
const PI_TIMEOUT_MS = envNum("PI_TIMEOUT_MS", 300_000);
|
|
6
7
|
/**
|
|
@@ -51,6 +52,28 @@ function skillFlags(mode, skillDir) {
|
|
|
51
52
|
}
|
|
52
53
|
}
|
|
53
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Extension flags. `--no-extensions` is ALWAYS present (it already was), and each
|
|
57
|
+
* declared path is added with `--extension`.
|
|
58
|
+
*
|
|
59
|
+
* Measured on pi 0.83.0: `--no-extensions --extension <path>` loads exactly the
|
|
60
|
+
* declared extension and nothing discovered, even with `-a` project-local trust
|
|
61
|
+
* active. Paths are resolved by the caller; a relative one handed to a child
|
|
62
|
+
* process running in a neutral cwd would silently resolve to nothing — the same
|
|
63
|
+
* class of failure as the `--skill` incident.
|
|
64
|
+
*/
|
|
65
|
+
function extensionFlags(extensions) {
|
|
66
|
+
if (!extensions || extensions.length === 0)
|
|
67
|
+
return [];
|
|
68
|
+
return extensions.flatMap((p) => {
|
|
69
|
+
const abs = resolve(p);
|
|
70
|
+
if (!existsSync(abs)) {
|
|
71
|
+
throw new Error(`env.extensions names ${abs}, which does not exist — pi would start without it and the ` +
|
|
72
|
+
`scenario would silently test an agent with no subagent tool at all.`);
|
|
73
|
+
}
|
|
74
|
+
return ["--extension", abs];
|
|
75
|
+
});
|
|
76
|
+
}
|
|
54
77
|
function header(turnNo, total, text) {
|
|
55
78
|
const label = total === 1 ? "USER" : `USER (turn ${turnNo}/${total})`;
|
|
56
79
|
return `>>> ${label}:\n${text}\n`;
|
|
@@ -91,6 +114,7 @@ export const piAdapter = {
|
|
|
91
114
|
const common = [
|
|
92
115
|
"--no-context-files",
|
|
93
116
|
"--no-extensions",
|
|
117
|
+
...extensionFlags(req.extensions),
|
|
94
118
|
"--provider",
|
|
95
119
|
req.model.provider,
|
|
96
120
|
"--model",
|
|
@@ -123,6 +147,72 @@ export const piAdapter = {
|
|
|
123
147
|
}
|
|
124
148
|
return parts.join("\n");
|
|
125
149
|
},
|
|
150
|
+
/**
|
|
151
|
+
* Structured run: same flags, same turn loop, plus `--mode json` and a trace
|
|
152
|
+
* per turn.
|
|
153
|
+
*
|
|
154
|
+
* Shares `skillFlags` and the turn structure with `run()` on purpose — if the
|
|
155
|
+
* two drifted, a trace-gated scenario would be measuring a different delivery
|
|
156
|
+
* than an ungated one, and the gate would be attesting to the wrong execution.
|
|
157
|
+
*
|
|
158
|
+
* The transcript is REBUILT from each turn's final assistant message rather
|
|
159
|
+
* than read from stdout, which is byte-identical to print mode's output (proven
|
|
160
|
+
* on a deterministic prompt; see docs/pi-native-capture-design-2026-08-08.md §2).
|
|
161
|
+
*/
|
|
162
|
+
async runStructured(req) {
|
|
163
|
+
const common = [
|
|
164
|
+
"--no-context-files",
|
|
165
|
+
"--no-extensions",
|
|
166
|
+
...extensionFlags(req.extensions),
|
|
167
|
+
"--provider",
|
|
168
|
+
req.model.provider,
|
|
169
|
+
"--model",
|
|
170
|
+
req.model.model,
|
|
171
|
+
];
|
|
172
|
+
const flags = req.systemPromptFile
|
|
173
|
+
? ["--no-skills", "--append-system-prompt", readFileSync(req.systemPromptFile, "utf8")]
|
|
174
|
+
: skillFlags(req.mode, req.skillDir);
|
|
175
|
+
const piVersion = await this.version();
|
|
176
|
+
const total = req.turns.length;
|
|
177
|
+
const traces = [];
|
|
178
|
+
const parts = [];
|
|
179
|
+
const session = total === 1 ? null : mkdtempSync(join(tmpdir(), "sc-pi-session-"));
|
|
180
|
+
for (let i = 0; i < total; i++) {
|
|
181
|
+
const turnFlags = session === null
|
|
182
|
+
? ["--no-session"]
|
|
183
|
+
: i === 0
|
|
184
|
+
? ["--session-dir", session]
|
|
185
|
+
: ["--session-dir", session, "-c"];
|
|
186
|
+
const args = [...flags, ...common, "--mode", "json", ...turnFlags, "-p", req.turns[i]];
|
|
187
|
+
const r = await runPiJson({
|
|
188
|
+
args,
|
|
189
|
+
cwd: req.cwd,
|
|
190
|
+
timeoutMs: PI_TIMEOUT_MS,
|
|
191
|
+
piVersion,
|
|
192
|
+
subject: req.model,
|
|
193
|
+
scenarioId: req.scenarioId ?? "(unknown)",
|
|
194
|
+
mode: req.mode,
|
|
195
|
+
rep: req.rep ?? 0,
|
|
196
|
+
turn: i,
|
|
197
|
+
homeDir: homedir(),
|
|
198
|
+
});
|
|
199
|
+
// A stream with no terminal events at all is not evidence of a clean run.
|
|
200
|
+
// Fail loudly here rather than let an empty trace satisfy a `forbid_calls`
|
|
201
|
+
// gate — "the model called nothing" and "we recorded nothing" must not
|
|
202
|
+
// reach the scorer looking the same.
|
|
203
|
+
if (!r.isComplete) {
|
|
204
|
+
throw new Error(`pi --mode json produced no terminal events for turn ${i + 1}/${total}` +
|
|
205
|
+
` (exit ${r.code}${r.malformedLines ? `, ${r.malformedLines} malformed line(s)` : ""})` +
|
|
206
|
+
(r.stderr.trim() ? `: ${r.stderr.trim()}` : ""));
|
|
207
|
+
}
|
|
208
|
+
traces.push(r.trace);
|
|
209
|
+
parts.push(header(i + 1, total, req.turns[i]));
|
|
210
|
+
parts.push(`<<< ASSISTANT:\n${r.trace.final_text.trim()}\n`);
|
|
211
|
+
if (r.code !== 0)
|
|
212
|
+
parts.push(`[pi exited ${r.code} on turn ${i + 1}]\n${r.stderr.trim()}\n`);
|
|
213
|
+
}
|
|
214
|
+
return { transcript: parts.join("\n"), traces };
|
|
215
|
+
},
|
|
126
216
|
/**
|
|
127
217
|
* Run the judge: no skills, no context files, no session, single prompt.
|
|
128
218
|
* Judge provider `claude-code` routes to the Claude Code CLI (`claude -p`),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skill-harness/adapters",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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.
|
|
43
|
+
"@skill-harness/core": "0.7.0"
|
|
44
44
|
}
|
|
45
45
|
}
|