@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 +1 -1
- package/src/adapters/echo.ts +2 -0
- package/src/adapters/oracle.ts +2 -0
- package/src/adapters/types.ts +24 -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/report/aggregate.ts +4 -1
- package/src/report/report.ts +69 -18
- package/src/report/scorer-health.ts +35 -10
- package/src/runner/execute.ts +18 -5
- package/src/runtime/types.ts +13 -0
- package/src/store/db.ts +7 -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.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",
|
package/src/adapters/echo.ts
CHANGED
|
@@ -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" },
|
package/src/adapters/oracle.ts
CHANGED
|
@@ -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> {
|
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
|
}
|
|
@@ -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
|
-
/**
|
|
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.
|
|
12
|
+
version: "0.6.0", // 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). */
|
package/src/report/aggregate.ts
CHANGED
|
@@ -119,13 +119,16 @@ export function collectExperimentAggregate(
|
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
/**
|
|
122
|
-
* Leaderboard order:
|
|
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)
|
package/src/report/report.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|
|
165
|
-
* eval's pairs with scored > 0; null when fewer than 2
|
|
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
|
|
400
|
-
* 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.
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
34
|
-
|
|
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
|
|
66
|
-
* suspect when it is present and failing in every scored
|
|
67
|
-
* of those arms, and at least one other graded check is
|
|
68
|
-
* in every one of them. A name flagged `graded: false` in
|
|
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(
|
|
72
|
-
|
|
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(
|
|
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(
|
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";
|
|
@@ -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
|
-
|
|
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);
|
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
|
}
|
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[];
|