@workos/quickstudy 0.5.0 → 0.6.1

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.5.0",
3
+ "version": "0.6.1",
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",
@@ -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
  }
@@ -112,7 +116,11 @@ export interface AgentAdapter {
112
116
  export interface ContainerAttemptContext {
113
117
  runId: string;
114
118
  attemptId: string;
115
- /** 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
+ */
116
124
  prompt: string;
117
125
  trialIndex: number;
118
126
  /**
@@ -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.5.0", // x-release-please-version
12
+ version: "0.6.1", // 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). */
@@ -534,13 +534,28 @@ function dialUpstream(client: Socket, host: string, port: number, ready: (upstre
534
534
  });
535
535
  }
536
536
 
537
- /** Bidirectional tunnel wiring shared by CONNECT and plain forwards. */
537
+ /**
538
+ * Bidirectional tunnel wiring shared by CONNECT and plain forwards.
539
+ *
540
+ * \`pipe()\` already ends the destination once the source ends — after the
541
+ * buffered bytes have flushed. Destroying the client on the upstream's
542
+ * \`close\` threw that buffer away: an upstream that closes right after a
543
+ * large response (a registry tarball) left the client with a truncated body
544
+ * and an ECONNRESET, so \`npm install\` through the proxy failed at random.
545
+ * So the upstream's close only ENDS the client (flush, then FIN). The other
546
+ * direction stays a destroy: a client that is gone will never read what the
547
+ * upstream still has to say, and a merely ended upstream would sit paused
548
+ * and half-open until the origin gave up.
549
+ */
538
550
  function pipeBoth(client: Socket, upstream: Socket): void {
539
551
  client.pipe(upstream);
540
552
  upstream.pipe(client);
541
- client.on("close", () => upstream.destroy());
542
- upstream.on("close", () => client.destroy());
543
553
  client.on("error", () => upstream.destroy());
554
+ upstream.on("error", () => client.destroy());
555
+ client.on("close", () => upstream.destroy());
556
+ upstream.on("close", () => {
557
+ if (!client.writableEnded) client.end();
558
+ });
544
559
  }
545
560
 
546
561
  /** CONNECT host:port — the HTTPS path. The tunnel is opaque to the proxy. */
@@ -252,13 +252,28 @@ function dialUpstream(client: Socket, host: string, port: number, ready: (upstre
252
252
  });
253
253
  }
254
254
 
255
- /** Bidirectional tunnel wiring shared by CONNECT and plain forwards. */
255
+ /**
256
+ * Bidirectional tunnel wiring shared by CONNECT and plain forwards.
257
+ *
258
+ * `pipe()` already ends the destination once the source ends — after the
259
+ * buffered bytes have flushed. Destroying the client on the upstream's
260
+ * `close` threw that buffer away: an upstream that closes right after a
261
+ * large response (a registry tarball) left the client with a truncated body
262
+ * and an ECONNRESET, so `npm install` through the proxy failed at random.
263
+ * So the upstream's close only ENDS the client (flush, then FIN). The other
264
+ * direction stays a destroy: a client that is gone will never read what the
265
+ * upstream still has to say, and a merely ended upstream would sit paused
266
+ * and half-open until the origin gave up.
267
+ */
256
268
  function pipeBoth(client: Socket, upstream: Socket): void {
257
269
  client.pipe(upstream);
258
270
  upstream.pipe(client);
259
- client.on("close", () => upstream.destroy());
260
- upstream.on("close", () => client.destroy());
261
271
  client.on("error", () => upstream.destroy());
272
+ upstream.on("error", () => client.destroy());
273
+ client.on("close", () => upstream.destroy());
274
+ upstream.on("close", () => {
275
+ if (!client.writableEnded) client.end();
276
+ });
262
277
  }
263
278
 
264
279
  /** CONNECT host:port — the HTTPS path. The tunnel is opaque to the proxy. */
@@ -53,7 +53,7 @@ export class ReportError extends Error {
53
53
  }
54
54
  }
55
55
 
56
- export const REPORT_SCHEMA_VERSION = 10;
56
+ export const REPORT_SCHEMA_VERSION = 11;
57
57
 
58
58
  /**
59
59
  * Substrings that must never appear in generated report prose: at n≤5 trials
@@ -82,7 +82,7 @@ export function methodologyNote(trials: number): string {
82
82
  `those trials. Both describe this run's trials only — no wider claim. Telemetry (cost, tokens, surface ` +
83
83
  `usage) is summed only over attempts that reported it and shown as unavailable — never zero — when none did. ` +
84
84
  `SDK recall is reported per experiment as recalled / attempts, where attempts are the scored attempts that ` +
85
- `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 ` +
86
86
  `checks are recorded and tallied but never enter pass/fail. Scorer health lists checks that failed in every ` +
87
87
  `scored attempt of an eval under every arm while another check in the same attempts passed: a statement ` +
88
88
  `about the instrument, not about any treatment. Control experiments — an oracle that applies each eval's ` +
@@ -150,6 +150,12 @@ export interface ReportCheck {
150
150
  * schema_version < 7 artifacts; readers treat absence as graded.
151
151
  */
152
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;
153
159
  }
154
160
 
155
161
  /** One eval × experiment aggregation. Pairs with zero attempts are ABSENT, never 0%. */
@@ -313,7 +319,7 @@ export interface ReportJson {
313
319
  const USAGE_VALUE_CAP = 20;
314
320
 
315
321
  export function collectPairs(attempts: AttemptRecord[]): ReportPair[] {
316
- 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> }>();
317
323
  for (const attempt of attempts) {
318
324
  const key = `${attempt.evalId}|${attempt.experimentId}`;
319
325
  let entry = pairs.get(key);
@@ -350,10 +356,11 @@ export function collectPairs(attempts: AttemptRecord[]): ReportPair[] {
350
356
  entry.outcomes.add(false);
351
357
  }
352
358
  for (const check of attempt.result?.checks ?? []) {
353
- 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 };
354
360
  tally.total += 1;
355
361
  if (check.passed) tally.passed += 1;
356
362
  if (check.graded === false) tally.graded = false;
363
+ if (tally.kind === null && typeof check.kind === "string" && check.kind !== "") tally.kind = check.kind;
357
364
  entry.checks.set(check.name, tally);
358
365
  }
359
366
  }
@@ -365,7 +372,7 @@ export function collectPairs(attempts: AttemptRecord[]): ReportPair[] {
365
372
  pair.pass_at_k = collectPassAtK(pair.passed, pair.scored);
366
373
  pair.flaky = outcomes.size > 1;
367
374
  pair.checks = [...checks.entries()]
368
- .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 }))
369
376
  .sort((a, b) => a.name.localeCompare(b.name));
370
377
  result.push(pair);
371
378
  }
@@ -411,17 +418,24 @@ function treatmentOf(run: RunRecord, experimentId: string): string | null {
411
418
  * experiment; "unspecified" is its own explicit missing-value sentinel, so
412
419
  * this never invents a value the run did not pin.
413
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
+
414
426
  /**
415
427
  * SDK recall over one experiment's scored attempts: the denominator is every
416
- * completed attempt that recorded at least one `graded: false` check; an
417
- * 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.
418
432
  */
419
433
  export function collectRecall(attempts: AttemptRecord[], experimentId: string): ReportRecall {
420
434
  let total = 0;
421
435
  let recalled = 0;
422
436
  for (const attempt of attempts) {
423
437
  if (attempt.experimentId !== experimentId || attempt.status !== "completed") continue;
424
- const ungraded = (attempt.result?.checks ?? []).filter((check) => check.graded === false);
438
+ const ungraded = (attempt.result?.checks ?? []).filter(isSdkRecallCheck);
425
439
  if (ungraded.length === 0) continue;
426
440
  total += 1;
427
441
  if (ungraded.every((check) => check.passed)) recalled += 1;
@@ -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";
@@ -562,7 +562,7 @@ export async function executeRunV3(options: ExecuteRunV3Options): Promise<RunSum
562
562
  try {
563
563
  disposition =
564
564
  experiment.runtime.kind === "host"
565
- ? 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)] })
566
566
  : await runContainerAttemptV3(
567
567
  options,
568
568
  loadedEval,
@@ -661,7 +661,7 @@ async function runHostAttemptV3(
661
661
  attemptId,
662
662
  fixtureDir: sourceDir,
663
663
  workspaceDir: scratchDir,
664
- prompt: loadedEval.promptBody,
664
+ prompt: composePrompt(args.environment?.promptPreamble, loadedEval.promptBody),
665
665
  trialIndex: entry.trialIndex,
666
666
  }));
667
667
  } catch (err) {
@@ -868,13 +868,14 @@ async function runContainerAttemptV3(
868
868
  const promptCtx: ContainerAttemptContext = {
869
869
  runId,
870
870
  attemptId,
871
- 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),
872
874
  trialIndex: entry.trialIndex,
873
875
  ...(rewrittenServers !== undefined ? { mcpServers: rewrittenServers } : {}),
874
876
  webPolicy: environment?.webPolicy ?? "native-web-blocked",
875
877
  };
876
878
  // Treatment config files (e.g. the MCP treatment's vendor config).
877
- // The prompt itself is byte-identical across experiments.
878
879
  await inStage("environment", async () => {
879
880
  for (const [path, content] of Object.entries(agentAdapter.setupFiles(promptCtx))) {
880
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
  }