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 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-388d791",
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
- const problems = checkString("config.glob", glob, "a glob pattern");
2333
- if (typeof pattern !== "string") {
2334
- problems.push(["config.pattern", `must be a JavaScript regular expression, got ${show(pattern)}`]);
2335
- } else {
2336
- try {
2337
- new RegExp(pattern);
2338
- } catch (error) {
2339
- const reason = error.message.replace(/^Invalid regular expression: /, "");
2340
- problems.push(["config.pattern", `${show(pattern)} is not a valid regular expression: ${reason}`]);
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/testCase.ts
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, `${show3(name)} has no matching ${FOLDER}/${path2}`);
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 ${show3(value)}`);
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 ${show3(iterations)}`);
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 ${show3(task)}`);
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 ? `${show3(name)} belongs to task ${show3(owner.split("/")[2])}, not ${show3(task)}` : `${show3(name)} has no matching ${FOLDER}/${prompt}`);
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", `${show3(pair)} is not of the form harness@version/model`);
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", `${show3(pair)}: version ${show3(version)} is not in ${FOLDER}/${dir}/versions.ts`);
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", `${show3(pair)}: model ${show3(model)} is not in ${FOLDER}/${dir}/models.ts`);
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
- await mustRun(["run", "--name", name, "--pull", "never", "--entrypoint", "sh", image, "-c", script]);
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 = declaration("declareHarness", `{
3358
- applyParameter: (_config, ctx) => \`RUN npm install -g @anthropic-ai/claude-code@\${ctx.version}\`,
3359
- cli: (ctx) => \`claude -p "\${ctx.initialPrompt}" --model \${ctx.modelId} > \${ctx.resultPath}\`,
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 = declaration("declareTokenCostCollector", `(_raw, _ctx) => {
3369
- return { outcome: "unavailable", reason: "notReported" };
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
- async function harnessCommand(root, task, permutation, problems) {
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
- return harness2.cli({
3512
- version: versions[version].version,
3513
- modelId: models[model],
3514
- initialPrompt: initialPrompt2.prompt,
3515
- resultPath: RESULT_PATH
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 commands = await Promise.all(hashed.map((permutation) => harnessCommand(root, testCase2.task, permutation, problems)));
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
- number: p + 1,
3528
- permutation,
3529
- prerunImage: prerunImage(permutation.hash, imagePrefix),
3530
- iterations: Array.from({ length: testCase2.iterations }, (_, i) => ({
3531
- number: i + 1,
3532
- postrunImage: postrunImage(executionId, p + 1, i + 1, imagePrefix),
3533
- command: commands[p]
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
- var executionWrapper = (command) => [
3553
- "mkdir -p /thunderjar",
3554
- NOW_MS_FUNCTION,
3555
- "start=$(now_ms)",
3556
- `sh -c ${shellQuote(command)}`,
3557
- "code=$?",
3558
- "end=$(now_ms)",
3559
- `echo "{\\"startedAt\\":$start,\\"finishedAt\\":$end,\\"exitCode\\":$code}" > ${RUN_PATH}`
3560
- ].join(`
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
- function parseExecution(text) {
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
- if (!isEpochMs(startedAt) || !isEpochMs(finishedAt) || typeof exitCode !== "number") {
3572
- throw new DockerContainerError(`The execution wrapper left no valid ${RUN_PATH}: ${text === undefined ? "there is none" : text.trim()}`);
3573
- }
3574
- return { startedAt, finishedAt, exitCode };
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.command));
3579
- const execution = parseExecution(await readContainerFile(run.container, RUN_PATH));
3580
- const harnessResult = await readContainerFile(run.container, RESULT_PATH) ?? null;
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, harnessResult };
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
- async function openMeasurementContext(postrunImage2, container, harnessResult) {
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, harnessResult) {
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, harnessResult } = await runInContainer({
3989
+ const { execution, harnessResults } = await runInContainer({
3685
3990
  prerunImage: permutation.prerunImage,
3686
3991
  postrunImage: iteration.postrunImage,
3687
- command: iteration.command,
3992
+ turns: iteration.turns,
3993
+ env: permutation.env,
3994
+ runOptions: permutation.runOptions,
3995
+ timeoutSeconds: permutation.timeoutSeconds,
3688
3996
  container
3689
3997
  });
3690
- const record = containerRunRecord(run, permutation, iteration, execution, harnessResult);
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, execution);
4008
+ hooks.onRan?.(permutation, iteration, record);
3695
4009
  if (measurements.length === 0)
3696
4010
  continue;
3697
4011
  let opened = 0;
3698
- const open = () => openMeasurementContext(iteration.postrunImage, `${container}-measure-${++opened}`, harnessResult ?? "");
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 indent = (text, by) => text.split(`
3735
- `).map((line) => by + line).join(`
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
- indent(iteration.command, " ")
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, { startedAt, finishedAt, exitCode }) => {
4116
+ onRan: (_permutation, _iteration, { execution, harnessResult, tokenCosts }) => {
3793
4117
  lineOpen = false;
3794
- console.log(exitCode === 0 ? `done (${duration(finishedAt - startedAt)})` : `failed (exit ${exitCode})`);
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 code", String(execution.exitCode)]
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(message2) {
4036
- console.error(`Error: ${message2}`);
4414
+ function fail(message3) {
4415
+ console.error(`Error: ${message3}`);
4037
4416
  process.exitCode = 1;
4038
4417
  }
4039
- async function show4(cwd, executionId, containerRun, dataDir) {
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 show4(process.cwd(), executionId, containerRun, options.dataDir);
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-388d791",
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
+ }
@@ -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
- /** What a harness's `cli` gets, to build the command that runs the agent. */
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
- /** Where the agent's own report of the run goes. */
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
- /** Environment variables the agent needs, e.g. its API key. */
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
- /** Reads the token costs from what the harness's `cli` wrote to its result path. */
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,