@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 +1 -1
- package/src/adapters/types.ts +10 -2
- package/src/build-info.generated.ts +1 -1
- package/src/evals/result.ts +1 -1
- package/src/evals/types.ts +9 -0
- package/src/execution-policy.ts +12 -0
- package/src/experiments/types.ts +1 -1
- package/src/isolation/image-contexts.generated.ts +18 -3
- package/src/isolation/proxy/server.ts +18 -3
- package/src/report/report.ts +22 -8
- package/src/runner/execute.ts +6 -5
- package/src/runtime/types.ts +13 -0
- package/src/surface-usage.ts +246 -42
- package/ui/dist/index.html +8 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@workos/quickstudy",
|
|
3
|
-
"version": "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",
|
package/src/adapters/types.ts
CHANGED
|
@@ -60,7 +60,11 @@ export interface AttemptContext {
|
|
|
60
60
|
* it into the artifacts store afterwards.
|
|
61
61
|
*/
|
|
62
62
|
workspaceDir: string;
|
|
63
|
-
/**
|
|
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
|
-
/**
|
|
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.
|
|
12
|
+
version: "0.6.1", // x-release-please-version
|
|
13
13
|
revision: null,
|
|
14
14
|
dirty: null,
|
|
15
15
|
sourceHash: null,
|
package/src/evals/result.ts
CHANGED
|
@@ -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))));
|
package/src/evals/types.ts
CHANGED
|
@@ -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
|
}
|
package/src/execution-policy.ts
CHANGED
|
@@ -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
|
}
|
package/src/experiments/types.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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. */
|
package/src/report/report.ts
CHANGED
|
@@ -53,7 +53,7 @@ export class ReportError extends Error {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
export const REPORT_SCHEMA_VERSION =
|
|
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
|
|
417
|
-
* attempt is recalled when all of those checks
|
|
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(
|
|
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;
|
package/src/runner/execute.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/src/runtime/types.ts
CHANGED
|
@@ -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
|
}
|