@workos/quickstudy 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workos/quickstudy",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "An open-source, company-agnostic eval harness for measuring whether coding agents can complete real integration tasks, across an agent x feature x framework x surface matrix.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -13,6 +13,8 @@ import type { AgentAdapter, AttemptContext, AttemptOutcome, TranscriptEvent } fr
13
13
 
14
14
  export class EchoAdapter implements AgentAdapter {
15
15
  readonly name: string;
16
+ /** Does nothing, so every eval must fail it: a scorer it passes rewards doing nothing. */
17
+ readonly role = "negative-control" as const;
16
18
  readonly identity = {
17
19
  provider: "local", model: "echo-v1", reasoning: "none", cliVersion: "built-in",
18
20
  surfacePolicies: { docs: "no-network", mcp: "no-network", cli: "no-network" },
@@ -27,6 +27,7 @@ function oracleMetrics(): AttemptMetrics {
27
27
  /** Host variant: the prepared fixture already carries the solution overlay. */
28
28
  export class OracleAdapter implements AgentAdapter {
29
29
  readonly name = "oracle";
30
+ readonly role = "positive-control" as const;
30
31
  readonly identity = ORACLE_IDENTITY;
31
32
  readonly startingState = "solution" as const;
32
33
 
@@ -50,6 +51,7 @@ export class OracleContainerAdapter implements ContainerAgentAdapter {
50
51
  readonly name = "oracle";
51
52
  readonly identity = ORACLE_IDENTITY;
52
53
  readonly startingState = "solution" as const;
54
+ readonly role = "positive-control" as const;
53
55
  readonly requiredHostEnv: readonly string[] = [];
54
56
 
55
57
  setupFiles(): Record<string, string> {
@@ -60,7 +60,11 @@ export interface AttemptContext {
60
60
  * it into the artifacts store afterwards.
61
61
  */
62
62
  workspaceDir: string;
63
- /** The eval's task prompt, byte-identical across experiments. */
63
+ /**
64
+ * The prompt the agent receives: the eval's task body (byte-identical
65
+ * across experiments), preceded by the runtime's `promptPreamble` when the
66
+ * treatment declares one (recorded per pair in the manifest).
67
+ */
64
68
  prompt: string;
65
69
  trialIndex: number;
66
70
  }
@@ -82,12 +86,24 @@ export interface AttemptOutcome {
82
86
  */
83
87
  export type StartingState = "fixture" | "solution";
84
88
 
89
+ /**
90
+ * What an adapter's attempts are for. "agent" is an arm under measurement; a
91
+ * control checks the instrument instead: "positive-control" must pass (the
92
+ * built-in oracle applies each eval's reference solution) and
93
+ * "negative-control" must fail (the built-in echo adapter does nothing). The
94
+ * runner records the role of the adapter that actually runs each experiment
95
+ * (RunConfig.roles); the report never guesses it from a name.
96
+ */
97
+ export type AdapterRole = "agent" | "positive-control" | "negative-control";
98
+
85
99
  export interface AgentAdapter {
86
100
  /** The adapter name experiments select via `agent.adapter` (e.g. "echo"). */
87
101
  name: string;
88
102
  identity?: AdapterIdentityConfig;
89
103
  /** Starting workspace the runner prepares for this adapter (default "fixture"). */
90
104
  startingState?: StartingState;
105
+ /** What the adapter's attempts are for (default "agent"). */
106
+ role?: AdapterRole;
91
107
  runAttempt(ctx: AttemptContext): Promise<AttemptOutcome>;
92
108
  }
93
109
 
@@ -100,7 +116,11 @@ export interface AgentAdapter {
100
116
  export interface ContainerAttemptContext {
101
117
  runId: string;
102
118
  attemptId: string;
103
- /** The eval's task prompt, byte-identical across experiments. */
119
+ /**
120
+ * The prompt the agent receives: the eval's task body (byte-identical
121
+ * across experiments), preceded by the runtime's `promptPreamble` when the
122
+ * treatment declares one (recorded per pair in the manifest).
123
+ */
104
124
  prompt: string;
105
125
  trialIndex: number;
106
126
  /**
@@ -128,6 +148,8 @@ export interface ContainerAgentAdapter {
128
148
  identity?: AdapterIdentityConfig;
129
149
  /** Starting workspace the runner prepares for this adapter (default "fixture"). */
130
150
  startingState?: StartingState;
151
+ /** What the adapter's attempts are for (default "agent"). */
152
+ role?: AdapterRole;
131
153
  /** Command used during preflight to record the executable's exact version. */
132
154
  versionCommand?: readonly string[];
133
155
  /**
@@ -9,7 +9,7 @@ export const EMBEDDED_BUILD_INFO: {
9
9
  // (the `x-release-please-version` marker anchors the replacement). It is
10
10
  // restored as-is after a binary build (scripts/build-binary.ts), so the
11
11
  // committed source `--version` stays in lockstep with package.json.
12
- version: "0.4.0", // x-release-please-version
12
+ version: "0.6.0", // x-release-please-version
13
13
  revision: null,
14
14
  dirty: null,
15
15
  sourceHash: null,
@@ -3,7 +3,7 @@ export function validEvalResult(value: unknown): value is EvalResult {
3
3
  if (!value || typeof value !== "object") return false;
4
4
  const result = value as EvalResult;
5
5
  if (typeof result.passed !== "boolean" || !Array.isArray(result.checks)) return false;
6
- if (result.checks.some((c) => !c || typeof c.name !== "string" || c.name === "" || typeof c.passed !== "boolean" || (c.graded !== undefined && typeof c.graded !== "boolean"))) return false;
6
+ if (result.checks.some((c) => !c || typeof c.name !== "string" || c.name === "" || typeof c.passed !== "boolean" || (c.graded !== undefined && typeof c.graded !== "boolean") || (c.kind !== undefined && (typeof c.kind !== "string" || c.kind === "")))) return false;
7
7
  if (new Set(result.checks.map((c) => c.name)).size !== result.checks.length) return false;
8
8
  return result.judgments === undefined || (Array.isArray(result.judgments) && result.judgments.every((j) => j && typeof j === "object" && j.provenance === "semantic-judge" &&
9
9
  typeof j.provider === "string" && typeof j.model === "string" && [j.tokensIn, j.tokensOut, j.costUsd].every((v) => v === null || (typeof v === "number" && Number.isFinite(v) && v >= 0))));
@@ -56,6 +56,15 @@ export interface CheckResult {
56
56
  * recall from it, and the scorer must leave it out of `passed`. Absent = true.
57
57
  */
58
58
  graded?: boolean;
59
+ /**
60
+ * What a recorded check is about, so recorded checks of different natures
61
+ * never pool: `"sdk"` (did the agent use the product SDK) feeds SDK recall;
62
+ * any other tag (`"wiring"` for shape-of-code signals, …) is tallied and
63
+ * shown but never enters SDK recall. Absent on an ungraded check means
64
+ * `"sdk"` (the pre-tag convention). Free-form string; graded checks may
65
+ * carry one too, purely as a label.
66
+ */
67
+ kind?: string;
59
68
  notes?: string;
60
69
  evidence?: EvidenceReference[];
61
70
  }
@@ -24,8 +24,16 @@ export interface PairEnvironment {
24
24
  webPolicy: WebPolicy;
25
25
  pathTreatment: string | null;
26
26
  mcp: string | null;
27
+ /** Runtime text prepended to the eval prompt for this pair (null = prompt verbatim). */
28
+ promptPreamble: string | null;
27
29
  budgets: LifecycleBudgets;
28
30
  }
31
+
32
+ /** The prompt an agent receives: the runtime's preamble (if any), a blank line, the eval's verbatim body. */
33
+ export function composePrompt(preamble: string | null | undefined, body: string): string {
34
+ const text = preamble?.trim() ?? "";
35
+ return text === "" ? body : `${text}\n\n${body}`;
36
+ }
29
37
  export function pairKey(evalId: string, experimentId: string): string {
30
38
  return JSON.stringify([evalId, experimentId]);
31
39
  }
@@ -64,6 +72,10 @@ export function resolvePairEnvironments(options: {
64
72
  webPolicy: runtime.webPolicy?.() ?? "native-web-blocked",
65
73
  pathTreatment: runtime.pathTreatment?.() ?? null,
66
74
  mcp: options.mcpHashes?.[experiment.id] ?? null,
75
+ promptPreamble: (() => {
76
+ const text = runtime.promptPreamble?.(loaded.metadata)?.trim() ?? "";
77
+ return text === "" ? null : text;
78
+ })(),
67
79
  budgets: { ...budgets },
68
80
  };
69
81
  }
@@ -41,7 +41,7 @@ export interface Runtime extends RuntimeCapabilities {
41
41
  * Images and lifecycle budgets can never be exempted. All group members must
42
42
  * declare the same fields. Source/config identity still changes per attempt.
43
43
  */
44
- treatmentPolicyFields?: Array<"egress" | "webPolicy" | "pathTreatment" | "mcp">;
44
+ treatmentPolicyFields?: Array<"egress" | "webPolicy" | "pathTreatment" | "mcp" | "promptPreamble">;
45
45
  /** Container image tag (container runtimes; alternative to `containerImage`). */
46
46
  image?: string;
47
47
  /** Scripted command run in /workspace (container runtimes, this phase). */
@@ -119,13 +119,16 @@ export function collectExperimentAggregate(
119
119
  }
120
120
 
121
121
  /**
122
- * Leaderboard order: discriminating pass rate desc (nulls last), then the
122
+ * Leaderboard order: agent arms before controls (the oracle would otherwise
123
+ * lead every board), then discriminating pass rate desc (nulls last), then the
123
124
  * all-eval pass rate desc (nulls last), then experiment id — a total order,
124
125
  * so every consumer renders the same rows in the same sequence.
125
126
  */
126
127
  export function compareLeaderboard(a: ReportExperiment, b: ReportExperiment): number {
127
128
  const rate = (value: number | null | undefined): number => (value === null || value === undefined ? -1 : value);
129
+ const control = (experiment: ReportExperiment): number => ((experiment.role ?? "agent") === "agent" ? 0 : 1);
128
130
  return (
131
+ control(a) - control(b) ||
129
132
  rate(b.aggregate?.pass_rate_discriminating) - rate(a.aggregate?.pass_rate_discriminating) ||
130
133
  rate(b.aggregate?.pass_rate_all) - rate(a.aggregate?.pass_rate_all) ||
131
134
  a.experiment_id.localeCompare(b.experiment_id)
@@ -32,10 +32,16 @@ import { activeAttempts, type AttemptRecord, type ResultsStore, type RunRecord }
32
32
  import type { SurfaceUsage } from "../surface-usage.ts";
33
33
  import { collectPassAtK, displayPassAtK, type PassAtKEntry } from "./pass-at-k.ts";
34
34
  import { estimateRunCosts, type Pricing, type RunCostEstimate } from "./costs.ts";
35
- import { collectBands, collectScorerHealth, type EvalBand, type ScorerHealth } from "./scorer-health.ts";
35
+ import {
36
+ collectBands,
37
+ collectScorerHealth,
38
+ type EvalBand,
39
+ type ExperimentRole,
40
+ type ScorerHealth,
41
+ } from "./scorer-health.ts";
36
42
  import { collectExperimentAggregate, compareLeaderboard, type ExperimentAggregate } from "./aggregate.ts";
37
43
 
38
- export type { EvalBand, ScorerHealth, ScorerHealthSuspect } from "./scorer-health.ts";
44
+ export type { EvalBand, ExperimentRole, ScorerHealth, ScorerHealthSuspect } from "./scorer-health.ts";
39
45
  export type { ExperimentAggregate } from "./aggregate.ts";
40
46
  export { compareLeaderboard } from "./aggregate.ts";
41
47
 
@@ -47,7 +53,7 @@ export class ReportError extends Error {
47
53
  }
48
54
  }
49
55
 
50
- export const REPORT_SCHEMA_VERSION = 9;
56
+ export const REPORT_SCHEMA_VERSION = 11;
51
57
 
52
58
  /**
53
59
  * Substrings that must never appear in generated report prose: at n≤5 trials
@@ -76,10 +82,13 @@ export function methodologyNote(trials: number): string {
76
82
  `those trials. Both describe this run's trials only — no wider claim. Telemetry (cost, tokens, surface ` +
77
83
  `usage) is summed only over attempts that reported it and shown as unavailable — never zero — when none did. ` +
78
84
  `SDK recall is reported per experiment as recalled / attempts, where attempts are the scored attempts that ` +
79
- `recorded at least one ungraded check and recalled are those whose ungraded checks all passed. Ungraded ` +
85
+ `recorded at least one ungraded check of kind "sdk" (an ungraded check with no kind counts as "sdk") and recalled are those whose ungraded "sdk" checks all passed. Ungraded checks of any other kind are tallied and shown but never enter SDK recall. Ungraded ` +
80
86
  `checks are recorded and tallied but never enter pass/fail. Scorer health lists checks that failed in every ` +
81
87
  `scored attempt of an eval under every arm while another check in the same attempts passed: a statement ` +
82
- `about the instrument, not about any treatment.`
88
+ `about the instrument, not about any treatment. Control experiments — an oracle that applies each eval's ` +
89
+ `reference solution, a no-op that changes nothing — are reported alongside the agent experiments and ` +
90
+ `tagged as controls wherever they appear; bands and scorer health are decided over the agent experiments ` +
91
+ `only, and controls are ranked after every agent experiment.`
83
92
  );
84
93
  }
85
94
 
@@ -112,6 +121,13 @@ export interface ReportExperiment {
112
121
  experiment_id: string;
113
122
  treatment: string | null;
114
123
  agent: ReportAgentIdentity;
124
+ /**
125
+ * "agent" for an arm; "positive-control" (the oracle) and "negative-control"
126
+ * (the echo no-op) never decide a band or a scorer-health suspect. Optional on
127
+ * the wire: absent in schema_version < 10 artifacts; readers treat absence as
128
+ * "agent".
129
+ */
130
+ role?: ExperimentRole;
115
131
  /**
116
132
  * Optional on the wire: absent in schema_version < 7 artifacts, which the
117
133
  * export may still carry — readers must tolerate absence.
@@ -134,6 +150,12 @@ export interface ReportCheck {
134
150
  * schema_version < 7 artifacts; readers treat absence as graded.
135
151
  */
136
152
  graded: boolean;
153
+ /**
154
+ * The `kind` the scorer recorded for this check (`"sdk"`, `"wiring"`, …),
155
+ * or null when no attempt tagged it. Absent in schema_version < 11
156
+ * artifacts; readers treat absence as null.
157
+ */
158
+ kind: string | null;
137
159
  }
138
160
 
139
161
  /** One eval × experiment aggregation. Pairs with zero attempts are ABSENT, never 0%. */
@@ -161,8 +183,9 @@ export interface ReportPair {
161
183
  flaky: boolean;
162
184
  checks: ReportCheck[];
163
185
  /**
164
- * The eval's band, denormalised onto each of its pairs: decided over the
165
- * eval's pairs with scored > 0; null when fewer than 2 arms scored. Absent
186
+ * The eval's band, denormalised onto each of its pairs (control pairs too):
187
+ * decided over the eval's agent pairs with scored > 0; null when fewer than 2
188
+ * agent arms scored (before schema 10, controls counted as arms). Absent
166
189
  * in schema_version < 8 artifacts; readers treat absence as unbanded.
167
190
  */
168
191
  band: EvalBand | null;
@@ -296,7 +319,7 @@ export interface ReportJson {
296
319
  const USAGE_VALUE_CAP = 20;
297
320
 
298
321
  export function collectPairs(attempts: AttemptRecord[]): ReportPair[] {
299
- const pairs = new Map<string, { pair: ReportPair; checks: Map<string, { passed: number; total: number; graded: boolean }>; outcomes: Set<boolean> }>();
322
+ const pairs = new Map<string, { pair: ReportPair; checks: Map<string, { passed: number; total: number; graded: boolean; kind: string | null }>; outcomes: Set<boolean> }>();
300
323
  for (const attempt of attempts) {
301
324
  const key = `${attempt.evalId}|${attempt.experimentId}`;
302
325
  let entry = pairs.get(key);
@@ -333,10 +356,11 @@ export function collectPairs(attempts: AttemptRecord[]): ReportPair[] {
333
356
  entry.outcomes.add(false);
334
357
  }
335
358
  for (const check of attempt.result?.checks ?? []) {
336
- const tally = entry.checks.get(check.name) ?? { passed: 0, total: 0, graded: true };
359
+ const tally = entry.checks.get(check.name) ?? { passed: 0, total: 0, graded: true, kind: null };
337
360
  tally.total += 1;
338
361
  if (check.passed) tally.passed += 1;
339
362
  if (check.graded === false) tally.graded = false;
363
+ if (tally.kind === null && typeof check.kind === "string" && check.kind !== "") tally.kind = check.kind;
340
364
  entry.checks.set(check.name, tally);
341
365
  }
342
366
  }
@@ -348,7 +372,7 @@ export function collectPairs(attempts: AttemptRecord[]): ReportPair[] {
348
372
  pair.pass_at_k = collectPassAtK(pair.passed, pair.scored);
349
373
  pair.flaky = outcomes.size > 1;
350
374
  pair.checks = [...checks.entries()]
351
- .map(([name, tally]) => ({ name, passed: tally.passed, total: tally.total, graded: tally.graded }))
375
+ .map(([name, tally]) => ({ name, passed: tally.passed, total: tally.total, graded: tally.graded, kind: tally.kind }))
352
376
  .sort((a, b) => a.name.localeCompare(b.name));
353
377
  result.push(pair);
354
378
  }
@@ -394,17 +418,24 @@ function treatmentOf(run: RunRecord, experimentId: string): string | null {
394
418
  * experiment; "unspecified" is its own explicit missing-value sentinel, so
395
419
  * this never invents a value the run did not pin.
396
420
  */
421
+ /** An ungraded check that feeds SDK recall: kind "sdk", or no kind (the pre-tag convention). */
422
+ export function isSdkRecallCheck(check: { graded?: boolean | undefined; kind?: string | undefined }): boolean {
423
+ return check.graded === false && (check.kind === undefined || check.kind === "sdk");
424
+ }
425
+
397
426
  /**
398
427
  * SDK recall over one experiment's scored attempts: the denominator is every
399
- * completed attempt that recorded at least one `graded: false` check; an
400
- * attempt is recalled when all of those checks passed. Null rate at zero.
428
+ * completed attempt that recorded at least one `graded: false` check of kind
429
+ * "sdk" (or untagged); an attempt is recalled when all of those checks
430
+ * passed. Ungraded checks of any other kind (wiring, …) never enter. Null
431
+ * rate at zero.
401
432
  */
402
433
  export function collectRecall(attempts: AttemptRecord[], experimentId: string): ReportRecall {
403
434
  let total = 0;
404
435
  let recalled = 0;
405
436
  for (const attempt of attempts) {
406
437
  if (attempt.experimentId !== experimentId || attempt.status !== "completed") continue;
407
- const ungraded = (attempt.result?.checks ?? []).filter((check) => check.graded === false);
438
+ const ungraded = (attempt.result?.checks ?? []).filter(isSdkRecallCheck);
408
439
  if (ungraded.length === 0) continue;
409
440
  total += 1;
410
441
  if (ungraded.every((check) => check.passed)) recalled += 1;
@@ -412,6 +443,21 @@ export function collectRecall(attempts: AttemptRecord[], experimentId: string):
412
443
  return { attempts: total, recalled, rate: total > 0 ? recalled / total : null };
413
444
  }
414
445
 
446
+ /**
447
+ * An experiment's role as the run recorded it (RunConfig.roles, from the
448
+ * adapter that actually ran). A run from before roles were recorded keeps no
449
+ * record of which adapter ran, so every experiment in it reads as an agent —
450
+ * exactly as before roles existed; nothing is guessed from a name or identity.
451
+ */
452
+ function roleOf(run: RunRecord, experimentId: string): ExperimentRole {
453
+ return run.config.roles?.[experimentId] ?? "agent";
454
+ }
455
+
456
+ /** The run's control experiments, from the roles it recorded. */
457
+ function controlExperiments(run: RunRecord): Set<string> {
458
+ return new Set(run.config.experimentIds.filter((id) => roleOf(run, id) !== "agent"));
459
+ }
460
+
415
461
  function collectExperiments(
416
462
  run: RunRecord,
417
463
  attempts: AttemptRecord[],
@@ -425,6 +471,7 @@ function collectExperiments(
425
471
  return {
426
472
  experiment_id: experimentId,
427
473
  treatment: treatmentOf(run, experimentId),
474
+ role: roleOf(run, experimentId),
428
475
  agent: {
429
476
  adapter: agent?.adapter ?? "unspecified",
430
477
  provider: agent?.provider ?? "unspecified",
@@ -599,9 +646,10 @@ export function buildReport(options: BuildReportOptions): ReportJson {
599
646
  if (publication && publication.eligible.length === 0) throw new ReportError(`no eligible complete samples for run ${runId}: ${publication.omitted.map((p) => `${p.eval_id} × ${p.experiment_id}: ${p.reasons.join("; ")}`).join(" | ")}`);
600
647
  const attempts = publication ? current.filter((a) => publication.eligible.some((p) => p.eval_id === a.evalId && p.experiment_id === a.experimentId)) : current;
601
648
  const pairs = collectPairs(attempts);
602
- const bands = collectBands(pairs);
649
+ const controls = controlExperiments(run);
650
+ const bands = collectBands(pairs, controls);
603
651
  for (const pair of pairs) pair.band = bands.get(pair.eval_id) ?? null;
604
- const scorerHealth = collectScorerHealth(attempts, pairs);
652
+ const scorerHealth = collectScorerHealth(attempts, pairs, controls);
605
653
  const costEstimate = options.pricing === undefined ? undefined : estimateRunCosts(run, attempts, options.resultsDir, options.pricing);
606
654
 
607
655
  const methodology = methodologyNote(run.config.trials) + (costEstimate === undefined ? "" :
@@ -674,11 +722,12 @@ function renderLeaderboard(experiments: ReportExperiment[]): string[] {
674
722
  (typeof agg.estimated_cost_usd === "number" ? ` +$${agg.estimated_cost_usd.toFixed(4)} est.` : "");
675
723
  const recall = experiment.sdk_recall;
676
724
  const recallText = recall && recall.rate !== null ? `${recall.recalled}/${recall.attempts}` : "n/a";
677
- return [experiment.experiment_id, `${model}${reasoning}`, `${all} · ${disc}`, time, tokens, cost, recallText];
725
+ const id = (experiment.role ?? "agent") === "agent" ? experiment.experiment_id : `${experiment.experiment_id} (control)`;
726
+ return [id, `${model}${reasoning}`, `${all} · ${disc}`, time, tokens, cost, recallText];
678
727
  });
679
728
  const widths = header.map((title, i) => Math.max(title.length, ...rows.map((row) => row[i]!.length)));
680
729
  const format = (cells: string[]): string => ` ${cells.map((cell, i) => cell.padEnd(widths[i]!)).join(" ").trimEnd()}`;
681
- return ["leaderboard (benchmark suite, sorted by discriminating pass rate):", format(header), ...rows.map(format)];
730
+ return ["leaderboard (benchmark suite, sorted by discriminating pass rate; controls last):", format(header), ...rows.map(format)];
682
731
  }
683
732
 
684
733
  /** Render the report as plain text. Renders FROM the JSON, never the store. */
@@ -794,7 +843,9 @@ export function renderReportText(report: ReportJson): string {
794
843
  lines.push(` ${group.group}: compared (raw counts per arm)`);
795
844
  for (const arm of group.arms) {
796
845
  const cells = arm.evals.map((entry) => `${entry.eval_id} ${entry.display}`).join(", ");
797
- lines.push(` ${arm.experiment_id}${arm.treatment !== null ? ` [${arm.treatment}]` : ""}: ${cells || "no scored attempts"}`);
846
+ const role = experiments.find((experiment) => experiment.experiment_id === arm.experiment_id)?.role ?? "agent";
847
+ const control = role === "agent" ? "" : " (control)";
848
+ lines.push(` ${arm.experiment_id}${control}${arm.treatment !== null ? ` [${arm.treatment}]` : ""}: ${cells || "no scored attempts"}`);
798
849
  }
799
850
  }
800
851
  }
@@ -13,6 +13,15 @@ import type { ReportPair } from "./report.ts";
13
13
 
14
14
  export type EvalBand = "discriminating" | "solved-by-all" | "failed-by-all";
15
15
 
16
+ /**
17
+ * What an experiment is for (the run's recorded RunConfig.roles). A control
18
+ * says whether the instrument works — the oracle must pass, the echo no-op must
19
+ * fail — so bands and scorer health are decided over agent experiments only:
20
+ * with an oracle and a no-op in the run, no eval could otherwise be solved or
21
+ * failed by all.
22
+ */
23
+ export type ExperimentRole = import("../adapters/types.ts").AdapterRole;
24
+
16
25
  export interface ScorerHealthSuspect {
17
26
  eval_id: string;
18
27
  check: string;
@@ -30,10 +39,19 @@ export interface ScorerHealth {
30
39
  summary: string;
31
40
  }
32
41
 
33
- /** Per eval: over pairs with scored > 0. null when fewer than 2 arms scored. */
34
- export function collectBands(pairs: ReportPair[]): Map<string, EvalBand | null> {
42
+ /**
43
+ * Per eval: over agent pairs with scored > 0 (`controls` names the control
44
+ * experiments, which never decide a band). null when fewer than 2 agent arms
45
+ * scored. Every eval of the run gets an entry, control-only ones as null.
46
+ */
47
+ export function collectBands(
48
+ pairs: ReportPair[],
49
+ controls: ReadonlySet<string> = new Set(),
50
+ ): Map<string, EvalBand | null> {
35
51
  const byEval = new Map<string, ReportPair[]>();
36
52
  for (const pair of pairs) {
53
+ if (!byEval.has(pair.eval_id)) byEval.set(pair.eval_id, []);
54
+ if (controls.has(pair.experiment_id)) continue;
37
55
  const list = byEval.get(pair.eval_id) ?? [];
38
56
  list.push(pair);
39
57
  byEval.set(pair.eval_id, list);
@@ -62,18 +80,25 @@ export function scorerHealthSummary(suspects: ScorerHealthSuspect[]): string {
62
80
  }
63
81
 
64
82
  /**
65
- * Suspects come only from `failed-by-all` evals (≥2 scored arms). A check is a
66
- * suspect when it is present and failing in every scored (completed) attempt
67
- * of those arms, and at least one other graded check is present and passing
68
- * in every one of them. A name flagged `graded: false` in any attempt (the
69
- * pair tally's rule) is excluded from both sides.
83
+ * Suspects come only from `failed-by-all` evals (≥2 scored agent arms). A
84
+ * check is a suspect when it is present and failing in every scored
85
+ * (completed) attempt of those arms, and at least one other graded check is
86
+ * present and passing in every one of them. A name flagged `graded: false` in
87
+ * any attempt (the pair tally's rule) is excluded from both sides. Control
88
+ * experiments (`controls`) are never arms.
70
89
  */
71
- export function collectScorerHealth(attempts: AttemptRecord[], pairs: ReportPair[]): ScorerHealth {
72
- const bands = collectBands(pairs);
90
+ export function collectScorerHealth(
91
+ attempts: AttemptRecord[],
92
+ pairs: ReportPair[],
93
+ controls: ReadonlySet<string> = new Set(),
94
+ ): ScorerHealth {
95
+ const bands = collectBands(pairs, controls);
73
96
  const suspects: ScorerHealthSuspect[] = [];
74
97
  for (const [evalId, band] of bands) {
75
98
  if (band !== "failed-by-all") continue;
76
- const arms = pairs.filter((pair) => pair.eval_id === evalId && pair.scored > 0);
99
+ const arms = pairs.filter(
100
+ (pair) => pair.eval_id === evalId && pair.scored > 0 && !controls.has(pair.experiment_id),
101
+ );
77
102
  const experiments = arms.map((pair) => pair.experiment_id).sort((a, b) => a.localeCompare(b));
78
103
  const ungraded = new Set(arms.flatMap((pair) => pair.checks.filter((check) => check.graded === false).map((check) => check.name)));
79
104
  const scored = attempts.filter(
@@ -64,7 +64,7 @@ import {
64
64
  type McpServerConfig,
65
65
  } from "../isolation/mcp.ts";
66
66
  import { hashString } from "../hash.ts";
67
- import { DEFAULT_BUDGETS, pairKey, resolvePairEnvironments, type LifecycleBudgets, type PairEnvironment } from "../execution-policy.ts";
67
+ import { DEFAULT_BUDGETS, pairKey, resolvePairEnvironments, type LifecycleBudgets, type PairEnvironment, composePrompt } from "../execution-policy.ts";
68
68
  import { ulid } from "../ids.ts";
69
69
  import { attemptIdentityHash, buildRunManifest, type RunManifest, type RuntimeImageIdentity } from "../manifest.ts";
70
70
  import { isMovingModelAlias } from "../model-identity.ts";
@@ -506,6 +506,18 @@ export async function executeRunV3(options: ExecuteRunV3Options): Promise<RunSum
506
506
  // ON THE RUN: the manifest holds runtime config only as an opaque hash,
507
507
  // so the report could never recompute this after the fact.
508
508
  groups: compareGroups(options.experiments.map((entry) => entry.experiment), manifest),
509
+ // Roles come from the resolved adapter, never its name: a scripted
510
+ // container command is an agent whatever `agent.adapter` says.
511
+ roles: Object.fromEntries(
512
+ [...experimentsById.values()].map((experiment) => [
513
+ experiment.id,
514
+ experiment.runtime.kind === "host"
515
+ ? (hostAdapters[experiment.agent.adapter]?.role ?? "agent")
516
+ : (experiment.runtime.command ?? []).length > 0
517
+ ? "agent"
518
+ : (containerAgents.get(experiment.id)?.role ?? "agent"),
519
+ ]),
520
+ ),
509
521
  ...(offeredSurfaces !== undefined ? { surfaces: offeredSurfaces } : {}),
510
522
  };
511
523
 
@@ -550,7 +562,7 @@ export async function executeRunV3(options: ExecuteRunV3Options): Promise<RunSum
550
562
  try {
551
563
  disposition =
552
564
  experiment.runtime.kind === "host"
553
- ? await runHostAttemptV3(options, hostAdapters, loadedEval, experiment, entry, { runId, attemptId, attemptTimeoutMs, persisted })
565
+ ? await runHostAttemptV3(options, hostAdapters, loadedEval, experiment, entry, { runId, attemptId, attemptTimeoutMs, persisted, environment: manifest.pairs?.[pairKey(entry.evalId, entry.experimentId)] })
554
566
  : await runContainerAttemptV3(
555
567
  options,
556
568
  loadedEval,
@@ -649,7 +661,7 @@ async function runHostAttemptV3(
649
661
  attemptId,
650
662
  fixtureDir: sourceDir,
651
663
  workspaceDir: scratchDir,
652
- prompt: loadedEval.promptBody,
664
+ prompt: composePrompt(args.environment?.promptPreamble, loadedEval.promptBody),
653
665
  trialIndex: entry.trialIndex,
654
666
  }));
655
667
  } catch (err) {
@@ -856,13 +868,14 @@ async function runContainerAttemptV3(
856
868
  const promptCtx: ContainerAttemptContext = {
857
869
  runId,
858
870
  attemptId,
859
- prompt: loadedEval.promptBody,
871
+ // The eval body is byte-identical across experiments; a runtime's
872
+ // promptPreamble (recorded per pair in the manifest) may precede it.
873
+ prompt: composePrompt(environment?.promptPreamble, loadedEval.promptBody),
860
874
  trialIndex: entry.trialIndex,
861
875
  ...(rewrittenServers !== undefined ? { mcpServers: rewrittenServers } : {}),
862
876
  webPolicy: environment?.webPolicy ?? "native-web-blocked",
863
877
  };
864
878
  // Treatment config files (e.g. the MCP treatment's vendor config).
865
- // The prompt itself is byte-identical across experiments.
866
879
  await inStage("environment", async () => {
867
880
  for (const [path, content] of Object.entries(agentAdapter.setupFiles(promptCtx))) {
868
881
  await container.writeFile(path, content);
@@ -84,6 +84,19 @@ export interface RuntimeCapabilities {
84
84
  webPolicy?(): WebPolicy;
85
85
  /** Directory prepended to the container PATH (the CLI treatment), if any. */
86
86
  pathTreatment?(): string | undefined;
87
+ /**
88
+ * Text placed BEFORE the eval's prompt for every attempt under this
89
+ * runtime — the one sanctioned way to tell the agent what the treatment
90
+ * offers ("a `acme` CLI is on PATH; run `acme --help`"). An offered surface
91
+ * nobody mentions is rarely discovered, and an undiscovered surface
92
+ * measures nothing. Identity-bearing: the resolved text is recorded per
93
+ * pair in the run manifest (`pairs[].promptPreamble`), so group members
94
+ * that differ here must list `"promptPreamble"` in `treatmentPolicyFields`
95
+ * (and mirror the text under `config[treatment]`) or comparison is
96
+ * withheld. `undefined` or an empty string means the prompt is used
97
+ * verbatim.
98
+ */
99
+ promptPreamble?(metadata: EvalMetadata): string | undefined;
87
100
  /** Capabilities (`query`, `getClient`) exposed to the eval's scorer. */
88
101
  scorerCapabilities?(ctx: ScorerCapabilityContext): ScorerCapabilities;
89
102
  }
package/src/store/db.ts CHANGED
@@ -34,6 +34,13 @@ function toLimitKind(value: string | null): LimitKind | null {
34
34
 
35
35
  /** The run-level configuration recorded in `runs.config_json`. */
36
36
  export interface RunConfig {
37
+ /**
38
+ * Each experiment's role, from the adapter that actually ran it (a scripted
39
+ * container command is always "agent"). Recorded here — NOT in the manifest —
40
+ * like `groups`, so it never perturbs run identity. Absent on older runs: every
41
+ * experiment then reads as "agent".
42
+ */
43
+ roles?: Record<string, import("../adapters/types.ts").AdapterRole>;
37
44
  scheduler?: { concurrency: number; seed: string; policy: "seeded-hash-v1"; order: import("../plan.ts").AttemptPlan[] };
38
45
  trials: number;
39
46
  evalIds: string[];