thunderjar 0.1.3-preview-388d791 → 0.1.3-preview-e09ffa1
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/cli.js +478 -99
- package/package.json +6 -3
- package/src/api/claudeCode.ts +74 -0
- package/src/api/declarations.ts +69 -7
- package/src/api/index.ts +3 -0
package/dist/cli.js
CHANGED
|
@@ -2047,7 +2047,7 @@ var program = new Command;
|
|
|
2047
2047
|
// package.json
|
|
2048
2048
|
var package_default = {
|
|
2049
2049
|
name: "thunderjar",
|
|
2050
|
-
version: "0.1.3-preview-
|
|
2050
|
+
version: "0.1.3-preview-e09ffa1",
|
|
2051
2051
|
type: "module",
|
|
2052
2052
|
description: "Thunderjar",
|
|
2053
2053
|
license: "MIT",
|
|
@@ -2066,7 +2066,8 @@ var package_default = {
|
|
|
2066
2066
|
files: [
|
|
2067
2067
|
"dist",
|
|
2068
2068
|
"src/api",
|
|
2069
|
-
"!src/**/*.test.ts"
|
|
2069
|
+
"!src/**/*.test.ts",
|
|
2070
|
+
"!src/**/*.result.json"
|
|
2070
2071
|
],
|
|
2071
2072
|
publishConfig: {
|
|
2072
2073
|
access: "public"
|
|
@@ -2077,7 +2078,9 @@ var package_default = {
|
|
|
2077
2078
|
"lint:generate": "bun scripts/lint-layers.ts",
|
|
2078
2079
|
typecheck: "tsc --noEmit",
|
|
2079
2080
|
test: "bun test",
|
|
2080
|
-
"test:docker": "THUNDERJAR_DOCKER_TESTS=1 bun test .docker.test",
|
|
2081
|
+
"test:docker": "THUNDERJAR_DOCKER_TESTS=1 bun test --timeout 600000 .docker.test",
|
|
2082
|
+
"test:agent": "THUNDERJAR_AGENT_TESTS=1 bun test --timeout 900000 .agent.test",
|
|
2083
|
+
"test:package": "bun scripts/package-smoke-test.ts",
|
|
2081
2084
|
fixture: "bun scripts/fixture.ts",
|
|
2082
2085
|
"docs:dev": "vitepress dev docs",
|
|
2083
2086
|
"docs:build": "vitepress build docs",
|
|
@@ -2329,19 +2332,38 @@ var grep = {
|
|
|
2329
2332
|
},
|
|
2330
2333
|
checkConfig(config) {
|
|
2331
2334
|
const { glob, pattern } = config ?? {};
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2335
|
+
return [...checkString("config.glob", glob, "a glob pattern"), ...checkPattern(pattern)];
|
|
2336
|
+
}
|
|
2337
|
+
};
|
|
2338
|
+
function checkPattern(pattern) {
|
|
2339
|
+
if (typeof pattern !== "string")
|
|
2340
|
+
return [["config.pattern", `must be a JavaScript regular expression, got ${show(pattern)}`]];
|
|
2341
|
+
try {
|
|
2342
|
+
new RegExp(pattern);
|
|
2343
|
+
return [];
|
|
2344
|
+
} catch (error) {
|
|
2345
|
+
const reason = error.message.replace(/^Invalid regular expression: /, "");
|
|
2346
|
+
return [["config.pattern", `${show(pattern)} is not a valid regular expression: ${reason}`]];
|
|
2347
|
+
}
|
|
2348
|
+
}
|
|
2349
|
+
var replyMatches = {
|
|
2350
|
+
async evaluate({ pattern, turn }, ctx) {
|
|
2351
|
+
const reply = turn === undefined ? ctx.reply : ctx.replies[turn - 1];
|
|
2352
|
+
if (!reply)
|
|
2353
|
+
return { outcome: "skipped", reason: `no turn ${turn}: the initial prompt has ${ctx.replies.length}` };
|
|
2354
|
+
if (reply.outcome !== "collected")
|
|
2355
|
+
return { outcome: "skipped", reason: `no reply: ${reply.reason}` };
|
|
2356
|
+
return { outcome: "measured", data: new RegExp(pattern).test(reply.text) };
|
|
2357
|
+
},
|
|
2358
|
+
checkConfig(config) {
|
|
2359
|
+
const { pattern, turn } = config ?? {};
|
|
2360
|
+
const problems = checkPattern(pattern);
|
|
2361
|
+
if (turn !== undefined && (typeof turn !== "number" || !Number.isInteger(turn) || turn < 1)) {
|
|
2362
|
+
problems.push(["config.turn", `must be a turn, counting from 1, got ${show(turn)}`]);
|
|
2342
2363
|
}
|
|
2343
2364
|
return problems;
|
|
2344
|
-
}
|
|
2365
|
+
},
|
|
2366
|
+
usesContainer: false
|
|
2345
2367
|
};
|
|
2346
2368
|
var shellWord = (text) => `'${text.replaceAll("'", `'\\''`)}'`;
|
|
2347
2369
|
var dirname = (path2) => path2.includes("/") ? path2.slice(0, path2.lastIndexOf("/")) : ".";
|
|
@@ -2436,11 +2458,12 @@ var templateTest = {
|
|
|
2436
2458
|
},
|
|
2437
2459
|
bundledFiles: ["template"]
|
|
2438
2460
|
};
|
|
2439
|
-
var instruments = { fileCreated, grep, templateTest };
|
|
2461
|
+
var instruments = { fileCreated, grep, templateTest, replyMatches };
|
|
2440
2462
|
var aggregateMethods = {
|
|
2441
2463
|
fileCreated: ["rate", "count"],
|
|
2442
2464
|
grep: ["mean", "median", "min", "max", "sum"],
|
|
2443
|
-
templateTest: ["sum", "mean"]
|
|
2465
|
+
templateTest: ["sum", "mean"],
|
|
2466
|
+
replyMatches: ["rate", "count"]
|
|
2444
2467
|
};
|
|
2445
2468
|
var isInstrumentName = (name) => typeof name === "string" && Object.hasOwn(instruments, name);
|
|
2446
2469
|
|
|
@@ -2516,8 +2539,57 @@ async function loadMeasurements(root, task) {
|
|
|
2516
2539
|
return measurements;
|
|
2517
2540
|
}
|
|
2518
2541
|
|
|
2519
|
-
// src/config/
|
|
2542
|
+
// src/config/requiredEnv.ts
|
|
2543
|
+
var NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
2520
2544
|
var show3 = (value) => JSON.stringify(value) ?? String(value);
|
|
2545
|
+
function requiredEnvProblem(requiredEnv) {
|
|
2546
|
+
if (requiredEnv === undefined)
|
|
2547
|
+
return;
|
|
2548
|
+
if (!Array.isArray(requiredEnv))
|
|
2549
|
+
return `must be an array of environment variable names, got ${show3(requiredEnv)}`;
|
|
2550
|
+
const bad = requiredEnv.filter((name) => typeof name !== "string" || !NAME.test(name));
|
|
2551
|
+
if (bad.length > 0)
|
|
2552
|
+
return `${bad.map(show3).join(", ")} ${bad.length === 1 ? "is not an environment variable name" : "are not environment variable names"}`;
|
|
2553
|
+
return;
|
|
2554
|
+
}
|
|
2555
|
+
var requiredEnvOf = (harness) => harness.requiredEnv ?? [];
|
|
2556
|
+
async function checkRequiredEnv(root, testCase, env = Bun.env) {
|
|
2557
|
+
const harnesses = [...new Set(testCase.harnessModel.map((pair) => parsePair(pair).harness))];
|
|
2558
|
+
const problems = [];
|
|
2559
|
+
const missing = [];
|
|
2560
|
+
for (const name of harnesses) {
|
|
2561
|
+
const harness = await loadDeclaration(root, `${PARAMETERS}/harnessModels/${name}/index.ts`, "declareHarness", problems);
|
|
2562
|
+
for (const variable of harness ? requiredEnvOf(harness) : []) {
|
|
2563
|
+
if (!env[variable])
|
|
2564
|
+
missing.push(`${name} needs ${variable}, which is not set.`);
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
if (problems.length > 0)
|
|
2568
|
+
throw problemsError(problems);
|
|
2569
|
+
if (missing.length === 0)
|
|
2570
|
+
return;
|
|
2571
|
+
const hint = `Export ${missing.length === 1 ? "it" : "them"} in the shell that runs thunderjar.`;
|
|
2572
|
+
throw new ConfigError(...missing.slice(0, -1), `${missing.at(-1)}
|
|
2573
|
+
${hint}`);
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
// src/config/runOptions.ts
|
|
2577
|
+
var show4 = (value) => JSON.stringify(value) ?? String(value);
|
|
2578
|
+
var RESERVED = ["--name", "--pull", "--entrypoint", "--rm", "--detach", "-d"];
|
|
2579
|
+
function runOptionsProblem(runOptions) {
|
|
2580
|
+
if (runOptions === undefined)
|
|
2581
|
+
return;
|
|
2582
|
+
if (!Array.isArray(runOptions) || runOptions.some((option) => typeof option !== "string")) {
|
|
2583
|
+
return `must be an array of docker run options and their values, each a string, got ${show4(runOptions)}`;
|
|
2584
|
+
}
|
|
2585
|
+
const reserved = runOptions.filter((option) => RESERVED.includes(option.split("=")[0]));
|
|
2586
|
+
if (reserved.length > 0)
|
|
2587
|
+
return `${reserved.map(show4).join(", ")} can't be given: Thunderjar sets ${reserved.length === 1 ? "it" : "them"} itself`;
|
|
2588
|
+
return;
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
// src/config/testCase.ts
|
|
2592
|
+
var show5 = (value) => JSON.stringify(value) ?? String(value);
|
|
2521
2593
|
var PARAMETER_FOLDERS = [
|
|
2522
2594
|
["baseImages", "base images"],
|
|
2523
2595
|
["codeStates", "code states"],
|
|
@@ -2558,7 +2630,7 @@ class TestCaseChecker {
|
|
|
2558
2630
|
async requires(field, name, path2) {
|
|
2559
2631
|
if (await existsIn(this.root, path2))
|
|
2560
2632
|
return true;
|
|
2561
|
-
this.problem(field, `${
|
|
2633
|
+
this.problem(field, `${show5(name)} has no matching ${FOLDER}/${path2}`);
|
|
2562
2634
|
return false;
|
|
2563
2635
|
}
|
|
2564
2636
|
arrays(testCase) {
|
|
@@ -2566,7 +2638,7 @@ class TestCaseChecker {
|
|
|
2566
2638
|
for (const field of ARRAY_FIELDS) {
|
|
2567
2639
|
const value = testCase[field];
|
|
2568
2640
|
if (!Array.isArray(value) || value.some((name) => typeof name !== "string")) {
|
|
2569
|
-
this.problem(field, `must be an array of names, got ${
|
|
2641
|
+
this.problem(field, `must be an array of names, got ${show5(value)}`);
|
|
2570
2642
|
} else if (value.length === 0) {
|
|
2571
2643
|
this.problem(field, "must name at least one value, got []");
|
|
2572
2644
|
} else {
|
|
@@ -2577,7 +2649,7 @@ class TestCaseChecker {
|
|
|
2577
2649
|
}
|
|
2578
2650
|
checkIterations(iterations) {
|
|
2579
2651
|
if (typeof iterations !== "number" || !Number.isInteger(iterations) || iterations < 1) {
|
|
2580
|
-
this.problem("iterations", `must be a positive integer, got ${
|
|
2652
|
+
this.problem("iterations", `must be a positive integer, got ${show5(iterations)}`);
|
|
2581
2653
|
}
|
|
2582
2654
|
}
|
|
2583
2655
|
async checkFolderFields(names) {
|
|
@@ -2585,14 +2657,17 @@ class TestCaseChecker {
|
|
|
2585
2657
|
for (const name of names[field] ?? []) {
|
|
2586
2658
|
const index = `${PARAMETERS}/${folder}/${name}/index.ts`;
|
|
2587
2659
|
if (await this.requires(field, name, index)) {
|
|
2588
|
-
await loadDeclaration(this.root, index, declareFn, this.problems);
|
|
2660
|
+
const declaration = await loadDeclaration(this.root, index, declareFn, this.problems);
|
|
2661
|
+
const optionsProblem = field === "baseImage" && declaration && runOptionsProblem(declaration.runOptions);
|
|
2662
|
+
if (optionsProblem)
|
|
2663
|
+
this.problems.push({ file: index, field: "runOptions", message: optionsProblem });
|
|
2589
2664
|
}
|
|
2590
2665
|
}
|
|
2591
2666
|
}
|
|
2592
2667
|
}
|
|
2593
2668
|
async checkTask(task, initialPrompts) {
|
|
2594
2669
|
if (typeof task !== "string") {
|
|
2595
|
-
this.problem("task", `must be a task name, got ${
|
|
2670
|
+
this.problem("task", `must be a task name, got ${show5(task)}`);
|
|
2596
2671
|
return;
|
|
2597
2672
|
}
|
|
2598
2673
|
if (!await this.requires("task", task, `${PARAMETERS}/tasks/${task}/`))
|
|
@@ -2601,11 +2676,29 @@ class TestCaseChecker {
|
|
|
2601
2676
|
for (const name of initialPrompts) {
|
|
2602
2677
|
const prompt = `${PARAMETERS}/tasks/${task}/initial-prompts/${name}.ts`;
|
|
2603
2678
|
if (await existsIn(this.root, prompt)) {
|
|
2604
|
-
await loadDeclaration(this.root, prompt, "declareInitialPrompt", this.problems);
|
|
2679
|
+
const declaration = await loadDeclaration(this.root, prompt, "declareInitialPrompt", this.problems);
|
|
2680
|
+
const timeout = declaration?.timeoutSeconds;
|
|
2681
|
+
if (declaration && (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0)) {
|
|
2682
|
+
this.problems.push({ file: prompt, field: "timeoutSeconds", message: `must be a number of seconds above 0, got ${show5(timeout)}` });
|
|
2683
|
+
}
|
|
2684
|
+
if (declaration)
|
|
2685
|
+
this.checkPrompt(prompt, declaration);
|
|
2605
2686
|
continue;
|
|
2606
2687
|
}
|
|
2607
2688
|
const owner = new Bun.Glob(`${PARAMETERS}/tasks/*/initial-prompts/${name}.ts`).scanSync({ cwd: this.root }).next().value;
|
|
2608
|
-
this.problem("initialPrompt", owner ? `${
|
|
2689
|
+
this.problem("initialPrompt", owner ? `${show5(name)} belongs to task ${show5(owner.split("/")[2])}, not ${show5(task)}` : `${show5(name)} has no matching ${FOLDER}/${prompt}`);
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
checkPrompt(file, { prompt, turns }) {
|
|
2693
|
+
const problem = (field, message) => this.problems.push({ file, field, message });
|
|
2694
|
+
if (prompt !== undefined && turns !== undefined) {
|
|
2695
|
+
problem("turns", "can't be given beside prompt. Give a single prompt, or a list of turns.");
|
|
2696
|
+
} else if (turns !== undefined) {
|
|
2697
|
+
if (!Array.isArray(turns) || turns.length === 0 || turns.some((turn) => typeof turn !== "string")) {
|
|
2698
|
+
problem("turns", `must be a list of at least one prompt, got ${show5(turns)}`);
|
|
2699
|
+
}
|
|
2700
|
+
} else if (typeof prompt !== "string") {
|
|
2701
|
+
problem("prompt", `must be the prompt's text, or give a list of turns instead, got ${show5(prompt)}`);
|
|
2609
2702
|
}
|
|
2610
2703
|
}
|
|
2611
2704
|
async harness(name) {
|
|
@@ -2613,7 +2706,13 @@ class TestCaseChecker {
|
|
|
2613
2706
|
if (loaded)
|
|
2614
2707
|
return loaded;
|
|
2615
2708
|
const dir = `${PARAMETERS}/harnessModels/${name}`;
|
|
2616
|
-
await loadDeclaration(this.root, `${dir}/index.ts`, "declareHarness", this.problems);
|
|
2709
|
+
const declaration = await loadDeclaration(this.root, `${dir}/index.ts`, "declareHarness", this.problems);
|
|
2710
|
+
const envProblem = declaration && requiredEnvProblem(declaration.requiredEnv);
|
|
2711
|
+
if (envProblem)
|
|
2712
|
+
this.problems.push({ file: `${dir}/index.ts`, field: "requiredEnv", message: envProblem });
|
|
2713
|
+
if (declaration && declaration.collectReply !== undefined && typeof declaration.collectReply !== "function") {
|
|
2714
|
+
this.problems.push({ file: `${dir}/index.ts`, field: "collectReply", message: `must be a function of the result file's text, got ${show5(declaration.collectReply)}` });
|
|
2715
|
+
}
|
|
2617
2716
|
await loadDeclaration(this.root, `${dir}/collectTokenCosts.ts`, "declareTokenCostCollector", this.problems);
|
|
2618
2717
|
const harness = {
|
|
2619
2718
|
versions: await loadDeclaration(this.root, `${dir}/versions.ts`, "declareHarnessVersions", this.problems),
|
|
@@ -2626,7 +2725,7 @@ class TestCaseChecker {
|
|
|
2626
2725
|
for (const pair of pairs) {
|
|
2627
2726
|
const match = HARNESS_MODEL.exec(pair);
|
|
2628
2727
|
if (!match) {
|
|
2629
|
-
this.problem("harnessModel", `${
|
|
2728
|
+
this.problem("harnessModel", `${show5(pair)} is not of the form harness@version/model`);
|
|
2630
2729
|
continue;
|
|
2631
2730
|
}
|
|
2632
2731
|
const [, name, version, model] = match;
|
|
@@ -2635,10 +2734,10 @@ class TestCaseChecker {
|
|
|
2635
2734
|
continue;
|
|
2636
2735
|
const { versions, models } = await this.harness(name);
|
|
2637
2736
|
if (versions && !Object.hasOwn(versions, version)) {
|
|
2638
|
-
this.problem("harnessModel", `${
|
|
2737
|
+
this.problem("harnessModel", `${show5(pair)}: version ${show5(version)} is not in ${FOLDER}/${dir}/versions.ts`);
|
|
2639
2738
|
}
|
|
2640
2739
|
if (models && !Object.hasOwn(models, model)) {
|
|
2641
|
-
this.problem("harnessModel", `${
|
|
2740
|
+
this.problem("harnessModel", `${show5(pair)}: model ${show5(model)} is not in ${FOLDER}/${dir}/models.ts`);
|
|
2642
2741
|
}
|
|
2643
2742
|
}
|
|
2644
2743
|
}
|
|
@@ -2703,8 +2802,27 @@ async function mustRun(args) {
|
|
|
2703
2802
|
}
|
|
2704
2803
|
return result.stdout.toString();
|
|
2705
2804
|
}
|
|
2706
|
-
async function runContainer(image, name, script) {
|
|
2707
|
-
|
|
2805
|
+
async function runContainer(image, name, script, { env = [], runOptions = [], timeoutSeconds } = {}) {
|
|
2806
|
+
const binary = dockerBinary();
|
|
2807
|
+
const passed = env.flatMap((variable) => ["-e", variable]);
|
|
2808
|
+
const args = ["run", "--name", name, "--pull", "never", ...passed, ...runOptions, "--entrypoint", "sh", image, "-c", script];
|
|
2809
|
+
const child = Bun.spawn([binary, ...args], { env: { ...Bun.env }, stdin: "ignore", stdout: "ignore", stderr: "pipe" });
|
|
2810
|
+
let killing;
|
|
2811
|
+
const timer = timeoutSeconds === undefined ? undefined : setTimeout(() => {
|
|
2812
|
+
const killedAt2 = Date.now();
|
|
2813
|
+
killing = mustRun(["kill", name]).then(() => killedAt2, () => {
|
|
2814
|
+
return;
|
|
2815
|
+
});
|
|
2816
|
+
}, timeoutSeconds * 1000);
|
|
2817
|
+
const [exitCode, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
|
|
2818
|
+
clearTimeout(timer);
|
|
2819
|
+
const killedAt = await killing;
|
|
2820
|
+
if (killedAt !== undefined)
|
|
2821
|
+
return { timedOut: true, killedAt };
|
|
2822
|
+
if (exitCode !== 0) {
|
|
2823
|
+
throw new DockerContainerError(reasonOf(stderr) || `\`${binary} run\` exited ${exitCode}`);
|
|
2824
|
+
}
|
|
2825
|
+
return { timedOut: false };
|
|
2708
2826
|
}
|
|
2709
2827
|
async function readContainerFile(name, path2) {
|
|
2710
2828
|
const dir = `${Bun.env.TMPDIR || "/tmp"}/thunderjar-cp-${crypto.randomUUID()}`;
|
|
@@ -3185,6 +3303,24 @@ function displayPath(cwd, path2) {
|
|
|
3185
3303
|
}
|
|
3186
3304
|
|
|
3187
3305
|
// src/cli/format.ts
|
|
3306
|
+
function agentEnd(execution, harnessResult) {
|
|
3307
|
+
if (execution.timedOut)
|
|
3308
|
+
return `timed out after ${execution.timeoutSeconds}s`;
|
|
3309
|
+
const ended = execution.exitCode === 0 ? "done" : `exited ${execution.exitCode}`;
|
|
3310
|
+
return harnessResult === null ? `${ended}, no result file` : ended;
|
|
3311
|
+
}
|
|
3312
|
+
var endedCleanly = (execution, harnessResult) => !execution.timedOut && execution.exitCode === 0 && harnessResult !== null;
|
|
3313
|
+
var thousands = (n) => Math.round(n).toLocaleString("en-US");
|
|
3314
|
+
var tokensOf = ({ inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens }) => inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens;
|
|
3315
|
+
var totalTokens = (costs) => costs?.outcome === "collected" ? costs.models.reduce((sum, model) => sum + tokensOf(model), 0) : undefined;
|
|
3316
|
+
function knownCost(costs) {
|
|
3317
|
+
if (costs?.outcome !== "collected" || costs.models.length === 0)
|
|
3318
|
+
return;
|
|
3319
|
+
if (costs.models.some(({ costUsd, costBasis }) => costUsd === undefined || costBasis === "unknown"))
|
|
3320
|
+
return;
|
|
3321
|
+
return costs.models.reduce((sum, { costUsd }) => sum + costUsd, 0);
|
|
3322
|
+
}
|
|
3323
|
+
var dollars = (usd) => usd > 0 && usd < 0.0005 ? "<$0.001" : `$${usd.toFixed(3)}`;
|
|
3188
3324
|
var plural = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
3189
3325
|
var minute = (iso) => new Date(iso).toISOString().slice(0, 16).replace("T", " ");
|
|
3190
3326
|
|
|
@@ -3354,20 +3490,33 @@ var testCase = declaration("declareTestCase", `{
|
|
|
3354
3490
|
var baseImage = declaration("declareBaseImage", `{
|
|
3355
3491
|
applyParameter: () => \`FROM node:20-bookworm\`,
|
|
3356
3492
|
}`);
|
|
3357
|
-
var harness =
|
|
3358
|
-
|
|
3359
|
-
|
|
3493
|
+
var harness = `import { claudeCodeReply, declareHarness } from "thunderjar";
|
|
3494
|
+
|
|
3495
|
+
// Claude Code, installed as root, then run as the image's non-root \`node\` user: it won't skip its
|
|
3496
|
+
// permission prompts as root, and nobody is there to answer them.
|
|
3497
|
+
export default declareHarness({
|
|
3498
|
+
applyParameter: (_config, ctx) => \`RUN npm install -g @anthropic-ai/claude-code@\${ctx.version}
|
|
3499
|
+
RUN mkdir -p /thunderjar && chown -R node:node /workspace /thunderjar
|
|
3500
|
+
USER node\`,
|
|
3501
|
+
// The prompt is read from its file, so quotes and newlines reach the agent intact. Turn 0 starts
|
|
3502
|
+
// the session, and any later turn resumes it.
|
|
3503
|
+
cli: (ctx) =>
|
|
3504
|
+
\`claude -p "$(cat \${ctx.promptPath})" --model \${ctx.modelId} --output-format json --dangerously-skip-permissions\` +
|
|
3505
|
+
\` \${ctx.turn === 0 ? "--session-id" : "--resume"} \${ctx.sessionId} > \${ctx.resultPath}\`,
|
|
3506
|
+
collectReply: claudeCodeReply,
|
|
3360
3507
|
requiredEnv: ["ANTHROPIC_API_KEY"],
|
|
3361
|
-
}
|
|
3508
|
+
});
|
|
3509
|
+
`;
|
|
3362
3510
|
var harnessModels = declaration("declareHarnessModels", `{
|
|
3363
3511
|
"haiku-4-5": "claude-haiku-4-5-20251001",
|
|
3364
3512
|
}`);
|
|
3365
3513
|
var harnessVersions = declaration("declareHarnessVersions", `{
|
|
3366
3514
|
"2.1.283": { version: "2.1.283" },
|
|
3367
3515
|
}`);
|
|
3368
|
-
var collectTokenCosts =
|
|
3369
|
-
|
|
3370
|
-
|
|
3516
|
+
var collectTokenCosts = `import { claudeCodeTokenCosts, declareTokenCostCollector } from "thunderjar";
|
|
3517
|
+
|
|
3518
|
+
export default declareTokenCostCollector((raw) => claudeCodeTokenCosts(raw));
|
|
3519
|
+
`;
|
|
3371
3520
|
var initialPrompt = declaration("declareInitialPrompt", `{
|
|
3372
3521
|
prompt: "Create a file isPrime.ts exporting a function that tests for primality.",
|
|
3373
3522
|
timeoutSeconds: 600,
|
|
@@ -3477,6 +3626,7 @@ async function imageStatuses(permutations, imagePrefix) {
|
|
|
3477
3626
|
async function plan(cwd, name, imagePrefix) {
|
|
3478
3627
|
const root = await findThunderjarRoot(cwd);
|
|
3479
3628
|
const testCase2 = await loadTestCase(root, name);
|
|
3629
|
+
await checkRequiredEnv(root, testCase2);
|
|
3480
3630
|
const permutations = await hashPermutations(root, testCase2);
|
|
3481
3631
|
const { statuses, unavailable } = await imageStatuses(permutations, imagePrefix);
|
|
3482
3632
|
return {
|
|
@@ -3497,42 +3647,69 @@ function createPlanCommand() {
|
|
|
3497
3647
|
|
|
3498
3648
|
// src/execution/planExecution.ts
|
|
3499
3649
|
var RESULT_PATH = "/thunderjar/result.json";
|
|
3500
|
-
|
|
3650
|
+
var PROMPT_PATH = "/thunderjar/prompt.txt";
|
|
3651
|
+
function turnPaths(turn, count) {
|
|
3652
|
+
if (count === 1)
|
|
3653
|
+
return { promptPath: PROMPT_PATH, resultPath: RESULT_PATH };
|
|
3654
|
+
return { promptPath: `/thunderjar/turns/${turn + 1}/prompt.txt`, resultPath: `/thunderjar/turns/${turn + 1}/result.json` };
|
|
3655
|
+
}
|
|
3656
|
+
var promptsOf = (initialPrompt2) => initialPrompt2.turns ?? [initialPrompt2.prompt];
|
|
3657
|
+
async function loadHarness(root, task, permutation, problems) {
|
|
3501
3658
|
const { harness: name, version, model } = parsePair(permutation.harnessModel);
|
|
3502
3659
|
const dir = `${PARAMETERS}/harnessModels/${name}`;
|
|
3503
|
-
const [harness2, versions, models, initialPrompt2] = await Promise.all([
|
|
3660
|
+
const [baseImage2, harness2, collectTokenCosts2, versions, models, initialPrompt2] = await Promise.all([
|
|
3661
|
+
loadDeclaration(root, `${PARAMETERS}/baseImages/${permutation.baseImage}/index.ts`, "declareBaseImage", problems),
|
|
3504
3662
|
loadDeclaration(root, `${dir}/index.ts`, "declareHarness", problems),
|
|
3663
|
+
loadDeclaration(root, `${dir}/collectTokenCosts.ts`, "declareTokenCostCollector", problems),
|
|
3505
3664
|
loadDeclaration(root, `${dir}/versions.ts`, "declareHarnessVersions", problems),
|
|
3506
3665
|
loadDeclaration(root, `${dir}/models.ts`, "declareHarnessModels", problems),
|
|
3507
3666
|
loadDeclaration(root, `${PARAMETERS}/tasks/${task}/initial-prompts/${permutation.initialPrompt}.ts`, "declareInitialPrompt", problems)
|
|
3508
3667
|
]);
|
|
3509
|
-
if (!harness2 || !versions || !models || !initialPrompt2)
|
|
3668
|
+
if (!baseImage2 || !harness2 || !collectTokenCosts2 || !versions || !models || !initialPrompt2)
|
|
3510
3669
|
return;
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3670
|
+
const pinned = versions[version].version;
|
|
3671
|
+
return { baseImage: baseImage2, harness: harness2, collectTokenCosts: collectTokenCosts2, version: pinned, modelId: models[model], initialPrompt: initialPrompt2 };
|
|
3672
|
+
}
|
|
3673
|
+
function planTurns({ harness: harness2, version, modelId, initialPrompt: initialPrompt2 }, sessionId) {
|
|
3674
|
+
const prompts = promptsOf(initialPrompt2);
|
|
3675
|
+
return prompts.map((prompt, turn) => {
|
|
3676
|
+
const paths = turnPaths(turn, prompts.length);
|
|
3677
|
+
const command = harness2.cli({ version, modelId, initialPrompt: prompt, ...paths, turn, sessionId });
|
|
3678
|
+
return { prompt, ...paths, command };
|
|
3516
3679
|
});
|
|
3517
3680
|
}
|
|
3518
3681
|
async function planExecution(root, testCase2, executionId, imagePrefix = DEFAULT_IMAGE_PREFIX) {
|
|
3519
3682
|
const hashed = await hashPermutations(root, testCase2);
|
|
3520
3683
|
const problems = [];
|
|
3521
|
-
const
|
|
3684
|
+
const loaded = await Promise.all(hashed.map((permutation) => loadHarness(root, testCase2.task, permutation, problems)));
|
|
3522
3685
|
if (problems.length > 0)
|
|
3523
3686
|
throw problemsError(problems);
|
|
3524
3687
|
return {
|
|
3525
3688
|
executionId,
|
|
3526
|
-
permutations: hashed.map((permutation, p) =>
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3689
|
+
permutations: hashed.map((permutation, p) => {
|
|
3690
|
+
const harnessed = loaded[p];
|
|
3691
|
+
return {
|
|
3692
|
+
number: p + 1,
|
|
3693
|
+
permutation,
|
|
3694
|
+
prerunImage: prerunImage(permutation.hash, imagePrefix),
|
|
3695
|
+
env: requiredEnvOf(harnessed.harness),
|
|
3696
|
+
runOptions: harnessed.baseImage.runOptions ?? [],
|
|
3697
|
+
timeoutSeconds: harnessed.initialPrompt.timeoutSeconds,
|
|
3698
|
+
harness: harnessed.harness,
|
|
3699
|
+
collectTokenCosts: harnessed.collectTokenCosts,
|
|
3700
|
+
version: harnessed.version,
|
|
3701
|
+
modelId: harnessed.modelId,
|
|
3702
|
+
iterations: Array.from({ length: testCase2.iterations }, (_, i) => {
|
|
3703
|
+
const sessionId = crypto.randomUUID();
|
|
3704
|
+
return {
|
|
3705
|
+
number: i + 1,
|
|
3706
|
+
postrunImage: postrunImage(executionId, p + 1, i + 1, imagePrefix),
|
|
3707
|
+
sessionId,
|
|
3708
|
+
turns: planTurns(harnessed, sessionId)
|
|
3709
|
+
};
|
|
3710
|
+
})
|
|
3711
|
+
};
|
|
3712
|
+
})
|
|
3536
3713
|
};
|
|
3537
3714
|
}
|
|
3538
3715
|
|
|
@@ -3549,37 +3726,69 @@ var NOW_MS_FUNCTION = [
|
|
|
3549
3726
|
"}"
|
|
3550
3727
|
].join(`
|
|
3551
3728
|
`);
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3729
|
+
function executionWrapper(turns) {
|
|
3730
|
+
const folders = [...new Set(["/thunderjar", ...turns.map(({ promptPath }) => promptPath.slice(0, promptPath.lastIndexOf("/")))])];
|
|
3731
|
+
const many = turns.length > 1;
|
|
3732
|
+
const runTurn = ({ command }, i) => {
|
|
3733
|
+
const run = [
|
|
3734
|
+
"turn_start=$(now_ms)",
|
|
3735
|
+
`sh -c ${shellQuote(command)}`,
|
|
3736
|
+
"code=$?",
|
|
3737
|
+
...many ? [`turns="$turns${i === 0 ? "" : ","}{\\"startedAt\\":$turn_start,\\"finishedAt\\":$(now_ms),\\"exitCode\\":$code}"`] : []
|
|
3738
|
+
];
|
|
3739
|
+
return i === 0 ? run : ["if [ $code -eq 0 ]; then", ...run.map((line) => ` ${line}`), "fi"];
|
|
3740
|
+
};
|
|
3741
|
+
return [
|
|
3742
|
+
`mkdir -p ${folders.join(" ")}`,
|
|
3743
|
+
...turns.map(({ prompt, promptPath }) => `printf '%s' ${shellQuote(prompt)} > ${promptPath}`),
|
|
3744
|
+
NOW_MS_FUNCTION,
|
|
3745
|
+
"start=$(now_ms)",
|
|
3746
|
+
`echo "{\\"startedAt\\":$start}" > ${RUN_PATH}`,
|
|
3747
|
+
...many ? ['turns=""'] : [],
|
|
3748
|
+
...turns.flatMap(runTurn),
|
|
3749
|
+
"end=$(now_ms)",
|
|
3750
|
+
`echo "{\\"startedAt\\":$start,\\"finishedAt\\":$end,\\"exitCode\\":$code${many ? ",\\\"turns\\\":[$turns]" : ""}}" > ${RUN_PATH}`
|
|
3751
|
+
].join(`
|
|
3561
3752
|
`);
|
|
3753
|
+
}
|
|
3562
3754
|
var isEpochMs = (value) => Number.isInteger(value) && value >= 1000000000000 && value < 10000000000000;
|
|
3563
|
-
|
|
3755
|
+
var isTurn = (value) => {
|
|
3756
|
+
const { startedAt, finishedAt, exitCode } = value ?? {};
|
|
3757
|
+
return isEpochMs(startedAt) && isEpochMs(finishedAt) && typeof exitCode === "number";
|
|
3758
|
+
};
|
|
3759
|
+
function parseExecution(text, timeoutSeconds, killedAt) {
|
|
3564
3760
|
let parsed;
|
|
3565
3761
|
try {
|
|
3566
3762
|
parsed = text === undefined ? undefined : JSON.parse(text);
|
|
3567
3763
|
} catch {
|
|
3568
3764
|
parsed = undefined;
|
|
3569
3765
|
}
|
|
3570
|
-
const { startedAt, finishedAt, exitCode } = parsed ?? {};
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3766
|
+
const { startedAt, finishedAt, exitCode, turns } = parsed ?? {};
|
|
3767
|
+
const invalid = () => new DockerContainerError(`The execution wrapper left no valid ${RUN_PATH}: ${text === undefined ? "there is none" : text.trim()}`);
|
|
3768
|
+
if (!isEpochMs(startedAt))
|
|
3769
|
+
throw invalid();
|
|
3770
|
+
if (killedAt !== undefined)
|
|
3771
|
+
return { startedAt, finishedAt: killedAt, exitCode: null, timedOut: true, timeoutSeconds };
|
|
3772
|
+
if (!isEpochMs(finishedAt) || typeof exitCode !== "number")
|
|
3773
|
+
throw invalid();
|
|
3774
|
+
if (turns !== undefined && (!Array.isArray(turns) || !turns.every(isTurn)))
|
|
3775
|
+
throw invalid();
|
|
3776
|
+
return { startedAt, finishedAt, exitCode, timedOut: false, timeoutSeconds, ...turns && { turns } };
|
|
3575
3777
|
}
|
|
3576
3778
|
async function runInContainer(run) {
|
|
3577
3779
|
try {
|
|
3578
|
-
await runContainer(run.prerunImage, run.container, executionWrapper(run.
|
|
3579
|
-
|
|
3580
|
-
|
|
3780
|
+
const stop = await runContainer(run.prerunImage, run.container, executionWrapper(run.turns), {
|
|
3781
|
+
env: run.env,
|
|
3782
|
+
runOptions: run.runOptions,
|
|
3783
|
+
timeoutSeconds: run.timeoutSeconds
|
|
3784
|
+
});
|
|
3785
|
+
const killedAt = stop.timedOut ? stop.killedAt : undefined;
|
|
3786
|
+
const execution = parseExecution(await readContainerFile(run.container, RUN_PATH), run.timeoutSeconds, killedAt);
|
|
3787
|
+
const harnessResults = [];
|
|
3788
|
+
for (const { resultPath } of run.turns)
|
|
3789
|
+
harnessResults.push(await readContainerFile(run.container, resultPath) ?? null);
|
|
3581
3790
|
await commitContainer(run.container, run.postrunImage, run.prerunImage);
|
|
3582
|
-
return { execution,
|
|
3791
|
+
return { execution, harnessResults };
|
|
3583
3792
|
} finally {
|
|
3584
3793
|
await removeContainer(run.container).catch(() => {});
|
|
3585
3794
|
}
|
|
@@ -3592,7 +3801,7 @@ async function measureContainerRun(measurements, open, now = () => new Date) {
|
|
|
3592
3801
|
const result = {};
|
|
3593
3802
|
for (const { name, declaration: declaration2, config: config2, contentHash } of measurements) {
|
|
3594
3803
|
const instrument = instruments[declaration2.instrument];
|
|
3595
|
-
const ctx = await open();
|
|
3804
|
+
const ctx = await open(instrument.usesContainer !== false);
|
|
3596
3805
|
let outcome;
|
|
3597
3806
|
try {
|
|
3598
3807
|
outcome = recordable(await instrument.evaluate(config2, ctx));
|
|
@@ -3608,7 +3817,23 @@ async function measureContainerRun(measurements, open, now = () => new Date) {
|
|
|
3608
3817
|
|
|
3609
3818
|
// src/execution/measurementContext.ts
|
|
3610
3819
|
var WORKDIR = "/workspace";
|
|
3611
|
-
|
|
3820
|
+
function recordedContext({ harnessResult, reply, replies }) {
|
|
3821
|
+
const noContainer = async () => {
|
|
3822
|
+
throw new Error("this instrument has no measurement container: it reads only what was recorded of the run");
|
|
3823
|
+
};
|
|
3824
|
+
return {
|
|
3825
|
+
workdir: WORKDIR,
|
|
3826
|
+
harnessResult,
|
|
3827
|
+
reply,
|
|
3828
|
+
replies,
|
|
3829
|
+
exec: noContainer,
|
|
3830
|
+
readFile: noContainer,
|
|
3831
|
+
writeFile: noContainer,
|
|
3832
|
+
glob: noContainer,
|
|
3833
|
+
close: async () => {}
|
|
3834
|
+
};
|
|
3835
|
+
}
|
|
3836
|
+
async function openMeasurementContext(postrunImage2, container, { harnessResult, reply, replies }) {
|
|
3612
3837
|
try {
|
|
3613
3838
|
await startContainer(postrunImage2, container);
|
|
3614
3839
|
} catch (error) {
|
|
@@ -3625,6 +3850,8 @@ async function openMeasurementContext(postrunImage2, container, harnessResult) {
|
|
|
3625
3850
|
return {
|
|
3626
3851
|
workdir: WORKDIR,
|
|
3627
3852
|
harnessResult,
|
|
3853
|
+
reply,
|
|
3854
|
+
replies,
|
|
3628
3855
|
exec: (command) => run(["sh", "-c", command]),
|
|
3629
3856
|
readFile: (path2) => mustRun2(`can't read ${path2}`, ["cat", "--", path2]),
|
|
3630
3857
|
async writeFile(path2, contents) {
|
|
@@ -3640,8 +3867,82 @@ async function openMeasurementContext(postrunImage2, container, harnessResult) {
|
|
|
3640
3867
|
};
|
|
3641
3868
|
}
|
|
3642
3869
|
|
|
3870
|
+
// src/execution/reply.ts
|
|
3871
|
+
var message2 = (error) => error instanceof Error ? error.message : String(error);
|
|
3872
|
+
function collectReply(collect, harnessResult) {
|
|
3873
|
+
if (harnessResult === null)
|
|
3874
|
+
return { outcome: "unavailable", reason: "the agent left no result file" };
|
|
3875
|
+
if (!collect)
|
|
3876
|
+
return { outcome: "unavailable", reason: "its harness has no collectReply" };
|
|
3877
|
+
let text;
|
|
3878
|
+
try {
|
|
3879
|
+
text = collect(harnessResult);
|
|
3880
|
+
} catch (error) {
|
|
3881
|
+
return { outcome: "unavailable", reason: `collectReply threw: ${message2(error)}` };
|
|
3882
|
+
}
|
|
3883
|
+
if (typeof text !== "string")
|
|
3884
|
+
return { outcome: "unavailable", reason: `collectReply returned ${typeof text}, not a string` };
|
|
3885
|
+
return { outcome: "collected", text };
|
|
3886
|
+
}
|
|
3887
|
+
|
|
3888
|
+
// src/execution/tokenCosts.ts
|
|
3889
|
+
var isCount = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
3890
|
+
function isTokenCosts(value) {
|
|
3891
|
+
const costs = value;
|
|
3892
|
+
if (typeof costs !== "object" || costs === null)
|
|
3893
|
+
return false;
|
|
3894
|
+
if (costs.outcome === "unavailable")
|
|
3895
|
+
return ["absent", "unparseable", "notReported"].includes(costs.reason);
|
|
3896
|
+
if (costs.outcome !== "collected" || !Array.isArray(costs.models))
|
|
3897
|
+
return false;
|
|
3898
|
+
if (costs.apiDurationMs !== undefined && !isCount(costs.apiDurationMs))
|
|
3899
|
+
return false;
|
|
3900
|
+
return costs.models.every((model) => {
|
|
3901
|
+
const counts = [model.inputTokens, model.outputTokens, model.cacheCreationTokens, model.cacheReadTokens];
|
|
3902
|
+
return typeof model.model === "string" && counts.every(isCount) && (model.costUsd === undefined || isCount(model.costUsd));
|
|
3903
|
+
});
|
|
3904
|
+
}
|
|
3905
|
+
function collectOne(collect, raw, ctx) {
|
|
3906
|
+
if (raw === null)
|
|
3907
|
+
return { outcome: "unavailable", reason: "absent" };
|
|
3908
|
+
try {
|
|
3909
|
+
const costs = collect(raw, ctx);
|
|
3910
|
+
return isTokenCosts(costs) ? costs : { outcome: "unavailable", reason: "unparseable" };
|
|
3911
|
+
} catch {
|
|
3912
|
+
return { outcome: "unavailable", reason: "unparseable" };
|
|
3913
|
+
}
|
|
3914
|
+
}
|
|
3915
|
+
function add(a, b) {
|
|
3916
|
+
const costUsd = a.costUsd !== undefined && b.costUsd !== undefined ? a.costUsd + b.costUsd : undefined;
|
|
3917
|
+
const costBasis = a.costBasis === b.costBasis ? a.costBasis : "unknown";
|
|
3918
|
+
return {
|
|
3919
|
+
model: a.model,
|
|
3920
|
+
inputTokens: a.inputTokens + b.inputTokens,
|
|
3921
|
+
outputTokens: a.outputTokens + b.outputTokens,
|
|
3922
|
+
cacheCreationTokens: a.cacheCreationTokens + b.cacheCreationTokens,
|
|
3923
|
+
cacheReadTokens: a.cacheReadTokens + b.cacheReadTokens,
|
|
3924
|
+
...costUsd !== undefined && { costUsd },
|
|
3925
|
+
...costBasis !== undefined && { costBasis }
|
|
3926
|
+
};
|
|
3927
|
+
}
|
|
3928
|
+
function collectTokenCosts2(collect, harnessResults, ctx) {
|
|
3929
|
+
const all = harnessResults.map((raw) => collectOne(collect, raw, ctx));
|
|
3930
|
+
const collected = all.filter((costs) => costs.outcome === "collected");
|
|
3931
|
+
if (collected.length === 0)
|
|
3932
|
+
return all[0] ?? { outcome: "unavailable", reason: "absent" };
|
|
3933
|
+
const byModel = new Map;
|
|
3934
|
+
for (const model of collected.flatMap((costs) => costs.models)) {
|
|
3935
|
+
const before = byModel.get(model.model);
|
|
3936
|
+
byModel.set(model.model, before ? add(before, model) : model);
|
|
3937
|
+
}
|
|
3938
|
+
const durations = collected.map((costs) => costs.apiDurationMs);
|
|
3939
|
+
const apiDurationMs = durations.every((ms) => ms !== undefined) ? durations.reduce((a, b) => a + b, 0) : undefined;
|
|
3940
|
+
return { outcome: "collected", models: [...byModel.values()], ...apiDurationMs !== undefined && { apiDurationMs } };
|
|
3941
|
+
}
|
|
3942
|
+
|
|
3643
3943
|
// src/execution/runExecution.ts
|
|
3644
|
-
function containerRunRecord({ plan: plan2, name, testCase: testCase2 }, { number: permutationNumber, permutation }, iteration, execution,
|
|
3944
|
+
function containerRunRecord({ plan: plan2, name, testCase: testCase2 }, { number: permutationNumber, permutation }, iteration, execution, turns, tokenCosts) {
|
|
3945
|
+
const last = turns.at(-1);
|
|
3645
3946
|
const { harness: harness2, version, model } = parsePair(permutation.harnessModel);
|
|
3646
3947
|
return {
|
|
3647
3948
|
executionId: plan2.executionId,
|
|
@@ -3655,7 +3956,11 @@ function containerRunRecord({ plan: plan2, name, testCase: testCase2 }, { number
|
|
|
3655
3956
|
parameters: Object.fromEntries(AXES.map((axis) => [axis, { name: permutation[axis], parameterHash: permutation.parameterHashes[axis] }])),
|
|
3656
3957
|
harnessModel: { harness: harness2, version, model, modelId: permutation.modelId },
|
|
3657
3958
|
execution,
|
|
3658
|
-
harnessResult,
|
|
3959
|
+
harnessResult: last.harnessResult,
|
|
3960
|
+
reply: last.reply,
|
|
3961
|
+
tokenCosts,
|
|
3962
|
+
sessionId: iteration.sessionId,
|
|
3963
|
+
...turns.length > 1 && { turns },
|
|
3659
3964
|
result: {}
|
|
3660
3965
|
};
|
|
3661
3966
|
}
|
|
@@ -3681,21 +3986,31 @@ async function runExecution(run, store, hooks = {}) {
|
|
|
3681
3986
|
for (const iteration of permutation.iterations) {
|
|
3682
3987
|
hooks.onRunning?.(permutation, iteration);
|
|
3683
3988
|
const container = containerName(plan2.executionId, permutation.number, iteration.number, imagePrefix);
|
|
3684
|
-
const { execution,
|
|
3989
|
+
const { execution, harnessResults } = await runInContainer({
|
|
3685
3990
|
prerunImage: permutation.prerunImage,
|
|
3686
3991
|
postrunImage: iteration.postrunImage,
|
|
3687
|
-
|
|
3992
|
+
turns: iteration.turns,
|
|
3993
|
+
env: permutation.env,
|
|
3994
|
+
runOptions: permutation.runOptions,
|
|
3995
|
+
timeoutSeconds: permutation.timeoutSeconds,
|
|
3688
3996
|
container
|
|
3689
3997
|
});
|
|
3690
|
-
const
|
|
3998
|
+
const turns = harnessResults.map((harnessResult2) => ({ harnessResult: harnessResult2, reply: collectReply(permutation.harness.collectReply, harnessResult2) }));
|
|
3999
|
+
const tokenCosts = collectTokenCosts2(permutation.collectTokenCosts, harnessResults, {
|
|
4000
|
+
version: permutation.version,
|
|
4001
|
+
modelId: permutation.modelId
|
|
4002
|
+
});
|
|
4003
|
+
const record = containerRunRecord(run, permutation, iteration, execution, turns, tokenCosts);
|
|
4004
|
+
const { harnessResult, reply } = record;
|
|
3691
4005
|
await store.recordContainerRun(record);
|
|
3692
4006
|
if (execution.exitCode !== 0)
|
|
3693
4007
|
status = "failed";
|
|
3694
|
-
hooks.onRan?.(permutation, iteration,
|
|
4008
|
+
hooks.onRan?.(permutation, iteration, record);
|
|
3695
4009
|
if (measurements.length === 0)
|
|
3696
4010
|
continue;
|
|
3697
4011
|
let opened = 0;
|
|
3698
|
-
const
|
|
4012
|
+
const recorded = { harnessResult: harnessResult ?? "", reply, replies: turns.map((turn) => turn.reply) };
|
|
4013
|
+
const open = async (usesContainer) => usesContainer ? openMeasurementContext(iteration.postrunImage, `${container}-measure-${++opened}`, recorded) : recordedContext(recorded);
|
|
3699
4014
|
const result = await measureContainerRun(measurements, open);
|
|
3700
4015
|
await store.recordContainerRun({ ...record, result });
|
|
3701
4016
|
hooks.onMeasured?.(permutation, iteration, result);
|
|
@@ -3731,15 +4046,22 @@ function ulid(now = Date.now(), random = crypto.getRandomValues(new Uint8Array(1
|
|
|
3731
4046
|
}
|
|
3732
4047
|
|
|
3733
4048
|
// src/cli/commands/run/run.ts
|
|
3734
|
-
var
|
|
3735
|
-
|
|
3736
|
-
`
|
|
4049
|
+
var FIELD_INDENT = " ";
|
|
4050
|
+
var LABEL_WIDTH2 = 10;
|
|
4051
|
+
var field = (label, value) => `${FIELD_INDENT}${`${label}:`.padEnd(LABEL_WIDTH2)}${value.split(`
|
|
4052
|
+
`).join(`
|
|
4053
|
+
${FIELD_INDENT}${" ".repeat(LABEL_WIDTH2)}`)}`;
|
|
3737
4054
|
function renderExecution({ executionId, permutations }) {
|
|
3738
4055
|
const blocks = permutations.map((permutation) => [
|
|
3739
4056
|
`Permutation ${permutation.number}: prerun image ${permutation.prerunImage}`,
|
|
3740
4057
|
...permutation.iterations.flatMap((iteration) => [
|
|
3741
4058
|
` Iteration ${iteration.number}: postrun image ${iteration.postrunImage}`,
|
|
3742
|
-
|
|
4059
|
+
...permutation.env.length > 0 ? [field("env", permutation.env.join(", "))] : [],
|
|
4060
|
+
...permutation.runOptions.length > 0 ? [field("docker", permutation.runOptions.join(" "))] : [],
|
|
4061
|
+
...iteration.turns.length === 1 ? [field("prompt", iteration.turns[0].prompt), field("command", iteration.turns[0].command)] : [
|
|
4062
|
+
field("session", iteration.sessionId),
|
|
4063
|
+
...iteration.turns.flatMap(({ prompt, command }, t) => [field(`prompt ${t + 1}`, prompt), field(`turn ${t + 1}`, command)])
|
|
4064
|
+
]
|
|
3743
4065
|
])
|
|
3744
4066
|
].join(`
|
|
3745
4067
|
`));
|
|
@@ -3751,6 +4073,7 @@ async function dryRun2(cwd, name, imagePrefix) {
|
|
|
3751
4073
|
const root = await findThunderjarRoot(cwd);
|
|
3752
4074
|
const executionId = ulid();
|
|
3753
4075
|
const testCase2 = await loadTestCase(root, name);
|
|
4076
|
+
await checkRequiredEnv(root, testCase2);
|
|
3754
4077
|
return renderExecution(await planExecution(root, testCase2, executionId, imagePrefix));
|
|
3755
4078
|
}
|
|
3756
4079
|
var ordinal = (n, total) => `${String(n).padStart(String(total).length)}/${total}`;
|
|
@@ -3767,6 +4090,7 @@ function label(testCase2, { permutation }) {
|
|
|
3767
4090
|
async function run(cwd, name, imagePrefix, dataDir) {
|
|
3768
4091
|
const root = await findThunderjarRoot(cwd);
|
|
3769
4092
|
const testCase2 = await loadTestCase(root, name);
|
|
4093
|
+
await checkRequiredEnv(root, testCase2);
|
|
3770
4094
|
const plan2 = await planExecution(root, testCase2, ulid(), imagePrefix);
|
|
3771
4095
|
const store = dataDirOf(cwd, root, dataDir);
|
|
3772
4096
|
const permutations = plan2.permutations.length;
|
|
@@ -3789,9 +4113,13 @@ async function run(cwd, name, imagePrefix, dataDir) {
|
|
|
3789
4113
|
lineOpen = true;
|
|
3790
4114
|
process.stdout.write(` ${ordinal(permutation.number, permutations)} iteration ${ordinal(iteration.number, testCase2.iterations)} ${label(testCase2, permutation)} ... `);
|
|
3791
4115
|
},
|
|
3792
|
-
onRan: (_permutation, _iteration, {
|
|
4116
|
+
onRan: (_permutation, _iteration, { execution, harnessResult, tokenCosts }) => {
|
|
3793
4117
|
lineOpen = false;
|
|
3794
|
-
|
|
4118
|
+
const ended = agentEnd(execution, harnessResult);
|
|
4119
|
+
const cost = knownCost(tokenCosts);
|
|
4120
|
+
const tokens = totalTokens(tokenCosts);
|
|
4121
|
+
const spent = cost !== undefined ? `, ${dollars(cost)}` : tokens !== undefined ? `, ${thousands(tokens)} tokens` : "";
|
|
4122
|
+
console.log(execution.timedOut ? ended : `${ended} (${duration(execution.finishedAt - execution.startedAt)}${spent})`);
|
|
3795
4123
|
},
|
|
3796
4124
|
onMeasured: (permutation, _iteration, result) => {
|
|
3797
4125
|
console.log(`${" ".repeat(ordinal(permutation.number, permutations).length + 3)}${showMeasures(result)}`);
|
|
@@ -3818,7 +4146,7 @@ async function run(cwd, name, imagePrefix, dataDir) {
|
|
|
3818
4146
|
}
|
|
3819
4147
|
}
|
|
3820
4148
|
function createRunCommand() {
|
|
3821
|
-
return new Command("run").description("Run a test case: one execution, with a new ID. Builds any missing prerun image, then runs each iteration of each permutation in a container from its prerun image, commits it into its postrun image, takes the task's measurements of it, each in a container from its postrun image, and records it in the run data store.").argument("<test case>", `the name of a test case, declared in ${FOLDER}/test-cases/<test case>.ts`).option("--dry-run", "print the images and harness commands the execution would use, without running anything").addOption(imagePrefixOption()).addOption(dataDirOption()).action(reportingConfigErrors(async (name, options) => {
|
|
4149
|
+
return new Command("run").description("Run a test case: one execution, with a new ID. Builds any missing prerun image, then runs each iteration of each permutation in a container from its prerun image, commits it into its postrun image, takes the task's measurements of it, each in a container from its postrun image, and records it in the run data store.").argument("<test case>", `the name of a test case, declared in ${FOLDER}/test-cases/<test case>.ts`).option("--dry-run", "print the images, environment variables, prompts and harness commands the execution would use, without running anything").addOption(imagePrefixOption()).addOption(dataDirOption()).action(reportingConfigErrors(async (name, options) => {
|
|
3822
4150
|
if (options.dryRun)
|
|
3823
4151
|
console.log(await dryRun2(process.cwd(), name, options.imagePrefix));
|
|
3824
4152
|
else
|
|
@@ -3924,6 +4252,33 @@ function duration2(ms) {
|
|
|
3924
4252
|
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
|
|
3925
4253
|
}
|
|
3926
4254
|
var runName = ({ permutation, iteration }) => `${permutation}-${iteration}`;
|
|
4255
|
+
var notCollected = (costs) => costs === undefined ? "not recorded" : costs.outcome === "unavailable" ? `not reported (${costs.reason})` : "";
|
|
4256
|
+
function costColumns(runs) {
|
|
4257
|
+
const mean2 = (xs) => xs.reduce((a, b) => a + b, 0) / xs.length;
|
|
4258
|
+
const tokens = runs.flatMap(({ tokenCosts }) => totalTokens(tokenCosts) ?? []);
|
|
4259
|
+
const costs = runs.flatMap(({ tokenCosts }) => knownCost(tokenCosts) ?? []);
|
|
4260
|
+
const star = (n) => n < runs.length ? "*" : "";
|
|
4261
|
+
return [
|
|
4262
|
+
tokens.length === 0 ? "not reported" : `${thousands(mean2(tokens))}${star(tokens.length)}`,
|
|
4263
|
+
costs.length === 0 ? "\u2014" : `${dollars(mean2(costs))}${star(costs.length)}`
|
|
4264
|
+
];
|
|
4265
|
+
}
|
|
4266
|
+
function tokenCostLines(costs) {
|
|
4267
|
+
if (costs.outcome === "unavailable")
|
|
4268
|
+
return [` none (${costs.reason})`];
|
|
4269
|
+
const cost = ({ costUsd, costBasis }) => costUsd === undefined ? "not reported" : costBasis === "unknown" ? `not reported (its price table doesn't know the model)` : dollars(costUsd);
|
|
4270
|
+
return columns([
|
|
4271
|
+
["model", "input", "output", "cache write", "cache read", "cost"],
|
|
4272
|
+
...costs.models.map((model) => [
|
|
4273
|
+
model.model,
|
|
4274
|
+
thousands(model.inputTokens),
|
|
4275
|
+
thousands(model.outputTokens),
|
|
4276
|
+
thousands(model.cacheCreationTokens),
|
|
4277
|
+
thousands(model.cacheReadTokens),
|
|
4278
|
+
cost(model)
|
|
4279
|
+
])
|
|
4280
|
+
]);
|
|
4281
|
+
}
|
|
3927
4282
|
function varyingAxes(runs) {
|
|
3928
4283
|
const varying = AXES.filter((axis) => new Set(runs.map(({ parameters }) => parameters[axis].name)).size > 1);
|
|
3929
4284
|
return varying.length > 0 ? varying : [...AXES];
|
|
@@ -3932,6 +4287,7 @@ function permutationTable(execution, runs, measurements) {
|
|
|
3932
4287
|
const axes = varyingAxes(runs);
|
|
3933
4288
|
const methodColumns = measurements.flatMap(({ name, declaration: declaration2 }) => declaration2.aggregate.map((method, i) => ({ name, method, first: i === 0 })));
|
|
3934
4289
|
const numberWidth = String(execution.matrix.permutations).length;
|
|
4290
|
+
const withCosts = runs.some(({ tokenCosts }) => tokenCosts !== undefined);
|
|
3935
4291
|
const rows = [];
|
|
3936
4292
|
const leftOut = [];
|
|
3937
4293
|
const incompatible = new Map;
|
|
@@ -3955,6 +4311,13 @@ function permutationTable(execution, runs, measurements) {
|
|
|
3955
4311
|
leftOut.push([runName(run2), name, showOutcome2(outcome)]);
|
|
3956
4312
|
}
|
|
3957
4313
|
}
|
|
4314
|
+
const costs = withCosts ? costColumns(own) : [];
|
|
4315
|
+
if (withCosts) {
|
|
4316
|
+
for (const run2 of own) {
|
|
4317
|
+
if (run2.tokenCosts?.outcome !== "collected")
|
|
4318
|
+
leftOut.push([runName(run2), "tokens", notCollected(run2.tokenCosts)]);
|
|
4319
|
+
}
|
|
4320
|
+
}
|
|
3958
4321
|
rows.push([
|
|
3959
4322
|
number,
|
|
3960
4323
|
axes.map((axis) => first.parameters[axis].name).join(" | "),
|
|
@@ -3967,10 +4330,11 @@ function permutationTable(execution, runs, measurements) {
|
|
|
3967
4330
|
if (value === undefined)
|
|
3968
4331
|
return "\u2014";
|
|
3969
4332
|
return `${showAggregate(method, value, measured)}${measured < own.length ? "*" : ""}`;
|
|
3970
|
-
})
|
|
4333
|
+
}),
|
|
4334
|
+
...costs
|
|
3971
4335
|
]);
|
|
3972
4336
|
}
|
|
3973
|
-
const header = ["", axes.join(" | "), "runs", ...methodColumns.map(({ method }) => method)];
|
|
4337
|
+
const header = ["", axes.join(" | "), "runs", ...methodColumns.map(({ method }) => method), ...withCosts ? ["tokens (mean)", "cost (mean)"] : []];
|
|
3974
4338
|
const widths = header.map((cell, i) => Math.max(cell.length, ...rows.map((row) => row[i]?.length ?? 0)));
|
|
3975
4339
|
for (const { name, declaration: declaration2 } of measurements) {
|
|
3976
4340
|
const start = 3 + methodColumns.findIndex((column2) => column2.name === name);
|
|
@@ -3983,6 +4347,10 @@ function permutationTable(execution, runs, measurements) {
|
|
|
3983
4347
|
const column = (i) => 1 + widths.slice(0, i).reduce((a, b) => a + b + GAP.length, 0);
|
|
3984
4348
|
const names = methodColumns.reduce((text, { name, first }, i) => first ? `${text.padEnd(column(3 + i))}${name}` : text, "");
|
|
3985
4349
|
const lines2 = [...measurements.length > 0 ? [names] : [], line(header), ...rows.map(line)];
|
|
4350
|
+
const unclean = runs.filter(({ execution: execution2, harnessResult }) => !endedCleanly(execution2, harnessResult));
|
|
4351
|
+
if (unclean.length > 0) {
|
|
4352
|
+
lines2.push("", "! Agents that didn't end cleanly:", ...columns(unclean.map((run2) => [runName(run2), agentEnd(run2.execution, run2.harnessResult)])));
|
|
4353
|
+
}
|
|
3986
4354
|
if (leftOut.length > 0)
|
|
3987
4355
|
lines2.push("", "* Left out of the aggregate:", ...columns(leftOut));
|
|
3988
4356
|
if (incompatible.size > 0) {
|
|
@@ -4005,8 +4373,15 @@ function renderExecution2(execution, runs, measurements) {
|
|
|
4005
4373
|
return [...header, "", ...body].join(`
|
|
4006
4374
|
`);
|
|
4007
4375
|
}
|
|
4376
|
+
var showReply = (reply, label2 = "") => {
|
|
4377
|
+
const lines2 = reply.outcome === "collected" ? reply.text.split(`
|
|
4378
|
+
`) : [`none (${reply.reason})`];
|
|
4379
|
+
return lines2.map((line, i) => ` ${i === 0 ? label2 : " ".repeat(label2.length)}${line}`.trimEnd());
|
|
4380
|
+
};
|
|
4381
|
+
var showReplies = ({ reply, turns }) => turns ? turns.flatMap((turn, i) => showReply(turn.reply, `Turn ${i + 1}: `)) : showReply(reply);
|
|
4008
4382
|
function renderContainerRun(record) {
|
|
4009
|
-
const { executionId, testCase: testCase2, permutation, permutationHash, iteration, postrunImage: postrunImage2, ranAt, parameters, harnessModel, execution, result } = record;
|
|
4383
|
+
const { executionId, testCase: testCase2, permutation, permutationHash, iteration, postrunImage: postrunImage2, ranAt, parameters, harnessModel, execution, harnessResult, reply, result } = record;
|
|
4384
|
+
const turnsRan = execution.turns === undefined ? [] : [["Turns", `${execution.turns.length} of ${record.turns?.length ?? "?"} ran`]];
|
|
4010
4385
|
const names = Object.keys(result).toSorted();
|
|
4011
4386
|
return [
|
|
4012
4387
|
...labelled([
|
|
@@ -4024,19 +4399,23 @@ function renderContainerRun(record) {
|
|
|
4024
4399
|
["Harness", `${harnessModel.harness} ${harnessModel.version}`],
|
|
4025
4400
|
["Model", `${harnessModel.model} (${harnessModel.modelId})`],
|
|
4026
4401
|
["Duration", duration2(execution.finishedAt - execution.startedAt)],
|
|
4027
|
-
["Exit
|
|
4402
|
+
["Exit", execution.timedOut ? `timed out after ${execution.timeoutSeconds}s` : String(execution.exitCode)],
|
|
4403
|
+
...turnsRan,
|
|
4404
|
+
["Result file", harnessResult === null ? "none" : plural(new TextEncoder().encode(harnessResult).length, "byte")]
|
|
4028
4405
|
]),
|
|
4029
4406
|
"",
|
|
4407
|
+
...reply === undefined ? [] : ["Reply", ...showReplies(record), ""],
|
|
4408
|
+
...record.tokenCosts === undefined ? [] : ["Token costs", ...tokenCostLines(record.tokenCosts), ""],
|
|
4030
4409
|
"Measures",
|
|
4031
4410
|
...names.length === 0 ? [" none"] : columns(names.map((name) => [name, showOutcome2(result[name].outcome)]))
|
|
4032
4411
|
].join(`
|
|
4033
4412
|
`);
|
|
4034
4413
|
}
|
|
4035
|
-
function fail(
|
|
4036
|
-
console.error(`Error: ${
|
|
4414
|
+
function fail(message3) {
|
|
4415
|
+
console.error(`Error: ${message3}`);
|
|
4037
4416
|
process.exitCode = 1;
|
|
4038
4417
|
}
|
|
4039
|
-
async function
|
|
4418
|
+
async function show6(cwd, executionId, containerRun, dataDir) {
|
|
4040
4419
|
const root = containerRun === undefined || dataDir === undefined ? await findThunderjarRoot(cwd) : undefined;
|
|
4041
4420
|
const store = fileStore(dataDir === undefined ? dataDirOf(cwd, root) : resolvePath(cwd, dataDir));
|
|
4042
4421
|
let execution;
|
|
@@ -4062,7 +4441,7 @@ async function show4(cwd, executionId, containerRun, dataDir) {
|
|
|
4062
4441
|
}
|
|
4063
4442
|
function createShowCommand() {
|
|
4064
4443
|
return new Command("show").description("Show an execution's recorded results: a row per permutation, with each measure aggregated across its iterations by its measurement's methods. Given a container run, show that one iteration instead: its parameters, how the agent ran, and each measure.").argument("<execution>", "the execution's ID, as listed by `thunderjar executions`").argument("[container run]", "one of its container runs, as `<permutation>-<iteration>`, e.g. `1-1`, or its postrun image").addOption(dataDirOption()).action(reportingConfigErrors(async (executionId, containerRun, options) => {
|
|
4065
|
-
await
|
|
4444
|
+
await show6(process.cwd(), executionId, containerRun, options.dataDir);
|
|
4066
4445
|
}));
|
|
4067
4446
|
}
|
|
4068
4447
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thunderjar",
|
|
3
|
-
"version": "0.1.3-preview-
|
|
3
|
+
"version": "0.1.3-preview-e09ffa1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Thunderjar",
|
|
6
6
|
"license": "MIT",
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"dist",
|
|
21
21
|
"src/api",
|
|
22
|
-
"!src/**/*.test.ts"
|
|
22
|
+
"!src/**/*.test.ts",
|
|
23
|
+
"!src/**/*.result.json"
|
|
23
24
|
],
|
|
24
25
|
"publishConfig": {
|
|
25
26
|
"access": "public"
|
|
@@ -30,7 +31,9 @@
|
|
|
30
31
|
"lint:generate": "bun scripts/lint-layers.ts",
|
|
31
32
|
"typecheck": "tsc --noEmit",
|
|
32
33
|
"test": "bun test",
|
|
33
|
-
"test:docker": "THUNDERJAR_DOCKER_TESTS=1 bun test .docker.test",
|
|
34
|
+
"test:docker": "THUNDERJAR_DOCKER_TESTS=1 bun test --timeout 600000 .docker.test",
|
|
35
|
+
"test:agent": "THUNDERJAR_AGENT_TESTS=1 bun test --timeout 900000 .agent.test",
|
|
36
|
+
"test:package": "bun scripts/package-smoke-test.ts",
|
|
34
37
|
"fixture": "bun scripts/fixture.ts",
|
|
35
38
|
"docs:dev": "vitepress dev docs",
|
|
36
39
|
"docs:build": "vitepress build docs",
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Reading what `claude -p --output-format json` writes: one JSON object, with the reply as
|
|
2
|
+
// `result`, and the tokens and cost of each model it used as `modelUsage`. For a harness's
|
|
3
|
+
// `collectReply` and `collectTokenCosts`, so a Claude Code harness needn't parse it itself.
|
|
4
|
+
//
|
|
5
|
+
// `modelUsage` is read, not the flat `usage`: `usage` counts only the top-level agent loop, so it
|
|
6
|
+
// leaves out what sub-agents spent. Its keys are camelCase, where `usage`'s are snake_case.
|
|
7
|
+
import type { ModelTokenCosts, TokenCosts } from "./declarations.ts";
|
|
8
|
+
|
|
9
|
+
type ModelUsage = {
|
|
10
|
+
inputTokens?: unknown;
|
|
11
|
+
outputTokens?: unknown;
|
|
12
|
+
cacheReadInputTokens?: unknown;
|
|
13
|
+
cacheCreationInputTokens?: unknown;
|
|
14
|
+
costUSD?: unknown;
|
|
15
|
+
costBasis?: unknown;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type ClaudeCodeResult = { type?: unknown; result?: unknown; modelUsage?: unknown; duration_api_ms?: unknown };
|
|
19
|
+
|
|
20
|
+
const COST_BASES = ["list", "managed", "unknown"] as const;
|
|
21
|
+
|
|
22
|
+
const count = (value: unknown) => (typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined);
|
|
23
|
+
|
|
24
|
+
/** `raw` parsed as Claude Code's result, or undefined if it isn't one. */
|
|
25
|
+
function parse(raw: string): ClaudeCodeResult | undefined {
|
|
26
|
+
let parsed: unknown;
|
|
27
|
+
try {
|
|
28
|
+
parsed = JSON.parse(raw);
|
|
29
|
+
} catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
return typeof parsed === "object" && parsed !== null && (parsed as ClaudeCodeResult).type === "result"
|
|
33
|
+
? (parsed as ClaudeCodeResult)
|
|
34
|
+
: undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The agent's final reply, from the result file Claude Code wrote. Throws if it isn't one. */
|
|
38
|
+
export function claudeCodeReply(raw: string): string {
|
|
39
|
+
const result = parse(raw)?.result;
|
|
40
|
+
if (typeof result !== "string") throw new Error("not a result of `claude -p --output-format json`");
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The tokens and cost of each model, from the result file Claude Code wrote. `unparseable` if it
|
|
46
|
+
* isn't one, and `notReported` if it counts no tokens at all, as a crashed session's does. Its
|
|
47
|
+
* cost is its own estimate, from a price table bundled with it: `costBasis` says whether that
|
|
48
|
+
* table knew the model.
|
|
49
|
+
*/
|
|
50
|
+
export function claudeCodeTokenCosts(raw: string): TokenCosts {
|
|
51
|
+
const result = parse(raw);
|
|
52
|
+
if (!result || typeof result.modelUsage !== "object" || result.modelUsage === null) return { outcome: "unavailable", reason: "unparseable" };
|
|
53
|
+
const models: ModelTokenCosts[] = [];
|
|
54
|
+
for (const [model, usage] of Object.entries(result.modelUsage as Record<string, ModelUsage>)) {
|
|
55
|
+
const tokens = [usage.inputTokens, usage.outputTokens, usage.cacheCreationInputTokens, usage.cacheReadInputTokens].map(count);
|
|
56
|
+
if (tokens.some((n) => n === undefined)) return { outcome: "unavailable", reason: "unparseable" };
|
|
57
|
+
const [inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens] = tokens as number[];
|
|
58
|
+
const costUsd = count(usage.costUSD);
|
|
59
|
+
const costBasis = COST_BASES.find((basis) => basis === usage.costBasis);
|
|
60
|
+
models.push({
|
|
61
|
+
model,
|
|
62
|
+
inputTokens: inputTokens as number,
|
|
63
|
+
outputTokens: outputTokens as number,
|
|
64
|
+
cacheCreationTokens: cacheCreationTokens as number,
|
|
65
|
+
cacheReadTokens: cacheReadTokens as number,
|
|
66
|
+
...(costUsd !== undefined && { costUsd }),
|
|
67
|
+
...(costBasis !== undefined && { costBasis }),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const total = models.reduce((sum, m) => sum + m.inputTokens + m.outputTokens + m.cacheCreationTokens + m.cacheReadTokens, 0);
|
|
71
|
+
if (total === 0) return { outcome: "unavailable", reason: "notReported" };
|
|
72
|
+
const apiDurationMs = count(result.duration_api_ms);
|
|
73
|
+
return { outcome: "collected", models, ...(apiDurationMs !== undefined && { apiDurationMs }) };
|
|
74
|
+
}
|
package/src/api/declarations.ts
CHANGED
|
@@ -73,6 +73,14 @@ type Applies<C, Ctx> = {
|
|
|
73
73
|
/** A base image's fragment is required: it carries the prerun image's `FROM`. */
|
|
74
74
|
export type BaseImageDeclaration<C = unknown> = Applies<C, ApplyParameterCtx> & {
|
|
75
75
|
applyParameter(config: C, ctx: ApplyParameterCtx): string;
|
|
76
|
+
/**
|
|
77
|
+
* More `docker run` options for the agent's containers, for agents that need more of the
|
|
78
|
+
* kernel than Docker allows by default, e.g. Claude Code's bubblewrap sandbox, which needs user
|
|
79
|
+
* namespaces: `["--security-opt", "seccomp=unconfined", "--security-opt", "apparmor=unconfined"]`.
|
|
80
|
+
* Each option and value is its own string. Ask for the least you need: `--privileged` works, but
|
|
81
|
+
* gives the agent the whole machine.
|
|
82
|
+
*/
|
|
83
|
+
runOptions?: string[];
|
|
76
84
|
};
|
|
77
85
|
|
|
78
86
|
export function declareBaseImage<C = undefined>(declaration: BaseImageDeclaration<C>): BaseImageDeclaration<C> {
|
|
@@ -96,18 +104,46 @@ export type HarnessApplyCtx = ApplyParameterCtx & {
|
|
|
96
104
|
version: string;
|
|
97
105
|
};
|
|
98
106
|
|
|
99
|
-
/**
|
|
107
|
+
/**
|
|
108
|
+
* What a harness's `cli` gets, to build the command that runs the agent. An initial prompt of
|
|
109
|
+
* several turns runs `cli` once per turn, in the same container, one after another.
|
|
110
|
+
*/
|
|
100
111
|
export type HarnessCliCtx = {
|
|
101
112
|
version: string;
|
|
102
113
|
modelId: string;
|
|
114
|
+
/**
|
|
115
|
+
* The turn's prompt text. Pasted into a shell command it breaks on the first quote, `$` or
|
|
116
|
+
* backtick, so read `promptPath` instead.
|
|
117
|
+
*/
|
|
103
118
|
initialPrompt: string;
|
|
104
|
-
/**
|
|
119
|
+
/**
|
|
120
|
+
* A file in the container holding the turn's prompt, exactly, with no newline added: pass it to
|
|
121
|
+
* the agent as `"$(cat ${ctx.promptPath})"`, or as a file if it takes one.
|
|
122
|
+
*/
|
|
123
|
+
promptPath: string;
|
|
124
|
+
/** Where the agent's own report of the turn goes. Each turn has its own. */
|
|
105
125
|
resultPath: string;
|
|
126
|
+
/** Which turn this is, counting from 0. A single prompt is turn 0. */
|
|
127
|
+
turn: number;
|
|
128
|
+
/**
|
|
129
|
+
* A fresh UUID for each container run, the same for each of its turns: start the agent's
|
|
130
|
+
* session with it on turn 0, and resume it on later turns, e.g. `--session-id`, then `--resume`.
|
|
131
|
+
*/
|
|
132
|
+
sessionId: string;
|
|
106
133
|
};
|
|
107
134
|
|
|
108
135
|
export type HarnessDeclaration<C = unknown> = Applies<C, HarnessApplyCtx> & {
|
|
109
136
|
cli: (ctx: HarnessCliCtx) => string;
|
|
110
|
-
/**
|
|
137
|
+
/**
|
|
138
|
+
* The agent's final reply, read from the text of the result file its `cli` left, for
|
|
139
|
+
* instruments such as `replyMatches` to measure. Without it, a run has no reply.
|
|
140
|
+
*/
|
|
141
|
+
collectReply?: (raw: string) => string;
|
|
142
|
+
/**
|
|
143
|
+
* The names of the environment variables the agent needs, e.g. its API key. Each is passed into
|
|
144
|
+
* this harness's containers only, as `docker run -e NAME`, so its value comes from the shell that
|
|
145
|
+
* runs Thunderjar, and is never written down. `plan` and `run` stop if one is unset.
|
|
146
|
+
*/
|
|
111
147
|
requiredEnv?: string[];
|
|
112
148
|
};
|
|
113
149
|
|
|
@@ -129,20 +165,31 @@ export function declareHarnessVersions(versions: HarnessVersionsDeclaration): Ha
|
|
|
129
165
|
return declared("declareHarnessVersions", versions);
|
|
130
166
|
}
|
|
131
167
|
|
|
168
|
+
/** One model's tokens, in four buckets priced differently, and its cost if the harness reports one. */
|
|
132
169
|
export type ModelTokenCosts = {
|
|
133
170
|
model: string;
|
|
134
171
|
inputTokens: number;
|
|
135
172
|
outputTokens: number;
|
|
136
173
|
cacheCreationTokens: number;
|
|
137
174
|
cacheReadTokens: number;
|
|
175
|
+
/** The harness's own estimate, never computed by Thunderjar. Absent if it reports none. */
|
|
138
176
|
costUsd?: number;
|
|
177
|
+
/** How far to trust `costUsd`: `unknown` when the harness's price table didn't know the model. */
|
|
178
|
+
costBasis?: "list" | "managed" | "unknown";
|
|
139
179
|
};
|
|
140
180
|
|
|
181
|
+
/**
|
|
182
|
+
* A container run's token costs, per model, or why there are none: no result file (`absent`), one
|
|
183
|
+
* that couldn't be read (`unparseable`), or one that counted nothing (`notReported`).
|
|
184
|
+
*/
|
|
141
185
|
export type TokenCosts =
|
|
142
|
-
| { outcome: "collected"; models: ModelTokenCosts[] }
|
|
186
|
+
| { outcome: "collected"; models: ModelTokenCosts[]; /** Time spent waiting on the model, if the harness reports it. */ apiDurationMs?: number }
|
|
143
187
|
| { outcome: "unavailable"; reason: "absent" | "unparseable" | "notReported" };
|
|
144
188
|
|
|
145
|
-
/**
|
|
189
|
+
/**
|
|
190
|
+
* Reads the token costs from what the harness's `cli` wrote to its result path. Thunderjar calls
|
|
191
|
+
* it only for a result file that exists, once per turn, and sums them. A throw is `unparseable`.
|
|
192
|
+
*/
|
|
146
193
|
export type TokenCostCollector = (raw: string, ctx: { version: string; modelId: string }) => TokenCosts;
|
|
147
194
|
|
|
148
195
|
export function declareTokenCostCollector(collector: TokenCostCollector): TokenCostCollector {
|
|
@@ -152,11 +199,14 @@ export function declareTokenCostCollector(collector: TokenCostCollector): TokenC
|
|
|
152
199
|
/**
|
|
153
200
|
* An initial prompt is one file, not a folder, so its fragment has no `ctx.dir`. It is used when
|
|
154
201
|
* the harness runs, so its fragment is usually left out.
|
|
202
|
+
*
|
|
203
|
+
* It's a single `prompt`, or a list of `turns`, each run by the harness's `cli` in turn, in the
|
|
204
|
+
* same container and agent session, so a later turn can ask about an earlier one. Its
|
|
205
|
+
* `timeoutSeconds` covers every turn together.
|
|
155
206
|
*/
|
|
156
207
|
export type InitialPromptDeclaration<C = unknown> = Applies<C, FragmentCtx> & {
|
|
157
|
-
prompt: string;
|
|
158
208
|
timeoutSeconds: number;
|
|
159
|
-
};
|
|
209
|
+
} & ({ prompt: string; turns?: never } | { turns: string[]; prompt?: never });
|
|
160
210
|
|
|
161
211
|
export function declareInitialPrompt<C = undefined>(declaration: InitialPromptDeclaration<C>): InitialPromptDeclaration<C> {
|
|
162
212
|
return declared("declareInitialPrompt", declaration);
|
|
@@ -185,9 +235,16 @@ export type MeasurementContext = {
|
|
|
185
235
|
glob(pattern: string): Promise<string[]>;
|
|
186
236
|
/** The text of the result file the harness's `cli` left, or "" if it left none. */
|
|
187
237
|
harnessResult: string;
|
|
238
|
+
/** The agent's final reply, from its harness's `collectReply`, or why there's none. */
|
|
239
|
+
reply: Reply;
|
|
240
|
+
/** Its reply to each turn, in order: one, unless its initial prompt has several turns. */
|
|
241
|
+
replies: Reply[];
|
|
188
242
|
workdir: string;
|
|
189
243
|
};
|
|
190
244
|
|
|
245
|
+
/** The agent's final reply, as its harness's `collectReply` read it from the result file, or why there's none. */
|
|
246
|
+
export type Reply = { outcome: "collected"; text: string } | { outcome: "unavailable"; reason: string };
|
|
247
|
+
|
|
191
248
|
/** Each built-in instrument's config and value, by name. */
|
|
192
249
|
export type InstrumentTypes = {
|
|
193
250
|
/** Did the agent leave a file matching `glob`? */
|
|
@@ -199,6 +256,11 @@ export type InstrumentTypes = {
|
|
|
199
256
|
* rendered against each file matching `subject` and run with `bun test`?
|
|
200
257
|
*/
|
|
201
258
|
templateTest: { config: { subject: string; template: string }; value: { passed: number; failed: number } };
|
|
259
|
+
/**
|
|
260
|
+
* Does the agent's reply match `pattern`, a JavaScript regular expression? Its reply to `turn`,
|
|
261
|
+
* counting from 1, of an initial prompt of several turns, or by default its last.
|
|
262
|
+
*/
|
|
263
|
+
replyMatches: { config: { pattern: string; turn?: number }; value: boolean };
|
|
202
264
|
};
|
|
203
265
|
|
|
204
266
|
export type InstrumentName = keyof InstrumentTypes;
|
package/src/api/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// The `thunderjar` library: what a project's `.thunderjar/` files import.
|
|
2
2
|
export { checkoutCommit } from "./checkoutCommit.ts";
|
|
3
|
+
export { claudeCodeReply, claudeCodeTokenCosts } from "./claudeCode.ts";
|
|
3
4
|
export {
|
|
4
5
|
declareBaseImage,
|
|
5
6
|
declareCodeState,
|
|
@@ -33,7 +34,9 @@ export type {
|
|
|
33
34
|
MeasurementContext,
|
|
34
35
|
MeasurementDeclaration,
|
|
35
36
|
MeasurementResult,
|
|
37
|
+
ModelTokenCosts,
|
|
36
38
|
PromptSetDeclaration,
|
|
39
|
+
Reply,
|
|
37
40
|
SqliteStore,
|
|
38
41
|
TestCaseDeclaration,
|
|
39
42
|
TokenCostCollector,
|