@workos/quickstudy 0.3.0 → 0.4.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/README.md +4 -0
- package/package.json +1 -1
- package/src/adapters/oracle.ts +67 -0
- package/src/adapters/types.ts +13 -0
- package/src/build-info.generated.ts +1 -1
- package/src/cli.ts +26 -3
- package/src/diagnose/excerpt.ts +19 -8
- package/src/diagnose/select.ts +1 -1
- package/src/diagnose/types.ts +5 -2
- package/src/evals/discovery.ts +32 -2
- package/src/evals/result.ts +1 -1
- package/src/evals/types.ts +12 -0
- package/src/export-types.ts +7 -0
- package/src/export.ts +15 -4
- package/src/manifest.ts +6 -0
- package/src/report/aggregate.ts +133 -0
- package/src/report/report.ts +137 -7
- package/src/report/scorer-health.ts +104 -0
- package/src/runner/context.ts +19 -2
- package/src/runner/execute.ts +148 -61
- package/src/runner/oracle-validate.ts +109 -0
- package/src/runner/workspace-source.ts +34 -0
- package/src/store/db.ts +58 -2
- package/src/store/schema.ts +7 -1
- package/ui/dist/index.html +11 -11
package/src/runner/execute.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* and never allowed to mask the attempt's result.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import {
|
|
24
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
25
25
|
import { tmpdir } from "node:os";
|
|
26
26
|
import { join } from "node:path";
|
|
27
27
|
import type {
|
|
@@ -33,10 +33,12 @@ import type {
|
|
|
33
33
|
import { ClaudeCodeAdapter } from "../adapters/claude.ts";
|
|
34
34
|
import { CodexAdapter } from "../adapters/codex.ts";
|
|
35
35
|
import { EchoAdapter } from "../adapters/echo.ts";
|
|
36
|
+
import { OracleAdapter, OracleContainerAdapter } from "../adapters/oracle.ts";
|
|
36
37
|
import { validEvalResult } from "../evals/result.ts";
|
|
37
38
|
import { importEvalScorer } from "../evals/discovery.ts";
|
|
38
39
|
import { deadline, StageTimeoutError } from "./deadline.ts";
|
|
39
40
|
import { scoreInWorker } from "./score-worker.ts";
|
|
41
|
+
import { prepWorkspaceSource } from "./workspace-source.ts";
|
|
40
42
|
import type { EvalResult, LoadedEval } from "../evals/types.ts";
|
|
41
43
|
import type { AgentSpec, Experiment, LoadedExperiment, Runtime } from "../experiments/types.ts";
|
|
42
44
|
import { canonicalJson } from "../identity-diff.ts";
|
|
@@ -70,7 +72,7 @@ import type { AttemptPlan } from "../plan.ts";
|
|
|
70
72
|
import type { ProvisionedEnvironment, RuntimeProvisionContext } from "../runtime/types.ts";
|
|
71
73
|
import { collectProviderKeys, hostSecretEnv, redactSecrets, validateProviderKeys, type ProviderKeyProbe } from "../secrets.ts";
|
|
72
74
|
import { ArtifactsStore } from "../store/artifacts.ts";
|
|
73
|
-
import { activeAttempts, type NewAttempt, type ResultsStore, type RunConfig } from "../store/db.ts";
|
|
75
|
+
import { activeAttempts, type ErrorKind, type NewAttempt, type ResultsStore, type RunConfig } from "../store/db.ts";
|
|
74
76
|
import { acquireRunLock } from "./run-lock.ts";
|
|
75
77
|
import { orderedPlan, schedule } from "./scheduler.ts";
|
|
76
78
|
import { compareGroups } from "../experiments/groups.ts";
|
|
@@ -136,17 +138,46 @@ interface AttemptDisposition {
|
|
|
136
138
|
status: "completed" | "incomplete" | "error";
|
|
137
139
|
result?: EvalResult;
|
|
138
140
|
error?: string;
|
|
141
|
+
/** Which stage failed; set on every `status: "error"` disposition. */
|
|
142
|
+
errorKind?: ErrorKind;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* An error tagged with the attempt stage it was thrown from. Stages wrap
|
|
147
|
+
* their own boundary (`inStage`) so helpers stay unaware of the taxonomy;
|
|
148
|
+
* the message is the cause's, so recorded errors read exactly as before.
|
|
149
|
+
*/
|
|
150
|
+
export class StageFailure extends Error {
|
|
151
|
+
constructor(readonly kind: ErrorKind, cause: unknown) {
|
|
152
|
+
super(cause instanceof Error ? cause.message : String(cause), { cause });
|
|
153
|
+
this.name = "StageFailure";
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The kind an error reaching a catch-all carries: its stage, else `harness`. */
|
|
158
|
+
export function errorKindOf(err: unknown): ErrorKind {
|
|
159
|
+
return err instanceof StageFailure ? err.kind : "harness";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function inStage<T>(kind: ErrorKind, work: () => Promise<T>): Promise<T> {
|
|
163
|
+
try {
|
|
164
|
+
return await work();
|
|
165
|
+
} catch (err) {
|
|
166
|
+
throw err instanceof StageFailure ? err : new StageFailure(kind, err);
|
|
167
|
+
}
|
|
139
168
|
}
|
|
140
169
|
|
|
141
170
|
function defaultHostAdapters(): Record<string, AgentAdapter> {
|
|
142
171
|
const echo = new EchoAdapter();
|
|
143
|
-
|
|
172
|
+
const oracle = new OracleAdapter();
|
|
173
|
+
return { [echo.name]: echo, [oracle.name]: oracle };
|
|
144
174
|
}
|
|
145
175
|
|
|
146
176
|
function defaultContainerAdapters(): Record<string, (agent: AgentSpec) => ContainerAgentAdapter> {
|
|
147
177
|
return {
|
|
148
178
|
claude: (agent) => new ClaudeCodeAdapter("claude", { model: agent.model, reasoning: agent.reasoning }),
|
|
149
179
|
codex: (agent) => new CodexAdapter("codex", { model: agent.model, reasoning: agent.reasoning }),
|
|
180
|
+
oracle: () => new OracleContainerAdapter(),
|
|
150
181
|
};
|
|
151
182
|
}
|
|
152
183
|
|
|
@@ -310,6 +341,12 @@ export async function executeRunV3(options: ExecuteRunV3Options): Promise<RunSum
|
|
|
310
341
|
);
|
|
311
342
|
}
|
|
312
343
|
if (experiment.runtime.kind === "container") {
|
|
344
|
+
if ((experiment.runtime.command ?? []).length > 0 && experiment.agent.adapter === "oracle") {
|
|
345
|
+
throw new Error(
|
|
346
|
+
`experiment "${experiment.id}" selects the oracle adapter but sets runtime.command, which bypasses it — ` +
|
|
347
|
+
`drop runtime.command so the reference solution is what gets scored`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
313
350
|
if ((experiment.runtime.command ?? []).length === 0 && !containerAgents.has(experiment.id)) {
|
|
314
351
|
const adapter = resolveContainerAdapter(experiment, containerAdapterFactories);
|
|
315
352
|
// Provider keys are validated here — before any run row, sidecar, or
|
|
@@ -334,6 +371,26 @@ export async function executeRunV3(options: ExecuteRunV3Options): Promise<RunSum
|
|
|
334
371
|
}
|
|
335
372
|
}
|
|
336
373
|
|
|
374
|
+
// Reference-solution preflight: an adapter that starts from the solution
|
|
375
|
+
// overlay (the oracle) cannot run an eval without solution/. Every
|
|
376
|
+
// offending pair is named at once, before any run row exists.
|
|
377
|
+
const missingSolutions: string[] = [];
|
|
378
|
+
for (const entry of plan) {
|
|
379
|
+
const experiment = experimentsById.get(entry.experimentId) as Experiment;
|
|
380
|
+
const startingState =
|
|
381
|
+
experiment.runtime.kind === "host"
|
|
382
|
+
? hostAdapters[experiment.agent.adapter]?.startingState
|
|
383
|
+
: containerAgents.get(experiment.id)?.startingState;
|
|
384
|
+
if (startingState === "solution" && (evalsById.get(entry.evalId) as LoadedEval).solutionDir === null) {
|
|
385
|
+
missingSolutions.push(`${entry.experimentId} × ${entry.evalId}`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
if (missingSolutions.length > 0) {
|
|
389
|
+
throw new Error(
|
|
390
|
+
`the oracle adapter needs solution/ — add it or drop the eval from the run: ${[...new Set(missingSolutions)].join(", ")}`,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
337
394
|
// Live key preflight: a key that is present but rejected by the provider
|
|
338
395
|
// would burn a container per attempt just to 401 inside it. Probe each
|
|
339
396
|
// unique agent's endpoint once — before any docker, sidecar, or run-row
|
|
@@ -507,10 +564,14 @@ export async function executeRunV3(options: ExecuteRunV3Options): Promise<RunSum
|
|
|
507
564
|
// An attempt failure never aborts the run: persist the error and move on.
|
|
508
565
|
const message = err instanceof Error ? err.message : String(err);
|
|
509
566
|
await artifacts.writeError(runId, attemptId, message);
|
|
510
|
-
disposition = { status: "error", error: message };
|
|
567
|
+
disposition = { status: "error", error: message, errorKind: errorKindOf(err) };
|
|
511
568
|
}
|
|
512
569
|
|
|
513
570
|
persisted.status = disposition.status;
|
|
571
|
+
persisted.errorKind = disposition.status === "error" ? (disposition.errorKind ?? "harness") : null;
|
|
572
|
+
// A limit hit means the workspace it left was graded; an unscored
|
|
573
|
+
// attempt (incomplete, or a later stage errored) carries none.
|
|
574
|
+
if (disposition.status !== "completed") persisted.limitHit = null;
|
|
514
575
|
if (disposition.judgeRecords) persisted.judgeRecords = disposition.judgeRecords;
|
|
515
576
|
if (disposition.result !== undefined) persisted.result = disposition.result;
|
|
516
577
|
if (disposition.error !== undefined) persisted.error = disposition.error;
|
|
@@ -556,15 +617,6 @@ async function mustExec(
|
|
|
556
617
|
return result;
|
|
557
618
|
}
|
|
558
619
|
|
|
559
|
-
/** Prepare the attempt's starting state: a copy of `local/`, or empty (tools-only evals). */
|
|
560
|
-
async function prepWorkspaceSource(loadedEval: LoadedEval): Promise<string> {
|
|
561
|
-
const sourceDir = await mkdtemp(join(tmpdir(), "quickstudy-v3-source-"));
|
|
562
|
-
if (loadedEval.localDir !== null) {
|
|
563
|
-
await cp(loadedEval.localDir, sourceDir, { recursive: true });
|
|
564
|
-
}
|
|
565
|
-
return sourceDir;
|
|
566
|
-
}
|
|
567
|
-
|
|
568
620
|
interface AttemptArgs {
|
|
569
621
|
runId: string;
|
|
570
622
|
attemptId: string;
|
|
@@ -585,10 +637,11 @@ async function runHostAttemptV3(
|
|
|
585
637
|
const { runId, attemptId, persisted } = args;
|
|
586
638
|
const adapter = hostAdapters[experiment.agent.adapter] as AgentAdapter;
|
|
587
639
|
|
|
588
|
-
const sourceDir = await prepWorkspaceSource(loadedEval);
|
|
589
|
-
const scratchDir = await mkdtemp(join(tmpdir(), "quickstudy-v3-attempt-"));
|
|
640
|
+
const sourceDir = await inStage("environment", () => prepWorkspaceSource(loadedEval, adapter.startingState ?? "fixture"));
|
|
641
|
+
const scratchDir = await inStage("environment", () => mkdtemp(join(tmpdir(), "quickstudy-v3-attempt-")));
|
|
590
642
|
try {
|
|
591
|
-
let outcome: AttemptOutcome;
|
|
643
|
+
let outcome: AttemptOutcome | undefined;
|
|
644
|
+
persisted.agentStartedAt = Date.now();
|
|
592
645
|
try {
|
|
593
646
|
outcome = await deadline("agent", args.attemptTimeoutMs, (signal) => adapter.runAttempt({
|
|
594
647
|
signal,
|
|
@@ -600,9 +653,19 @@ async function runHostAttemptV3(
|
|
|
600
653
|
trialIndex: entry.trialIndex,
|
|
601
654
|
}));
|
|
602
655
|
} catch (err) {
|
|
603
|
-
if (!(err instanceof StageTimeoutError)) throw err;
|
|
604
|
-
|
|
605
|
-
|
|
656
|
+
if (!(err instanceof StageTimeoutError)) throw new StageFailure("agent", err);
|
|
657
|
+
// Graded timeout: the adapter never returned (no transcript, diff, or
|
|
658
|
+
// metrics), but the scratch workspace it was writing into exists — the
|
|
659
|
+
// tree it left behind is exported and scored like any other.
|
|
660
|
+
await artifacts.writeError(runId, attemptId, `attempt timed out after ${args.attemptTimeoutMs}ms; workspace graded as left`);
|
|
661
|
+
persisted.limitHit = "timeout";
|
|
662
|
+
} finally {
|
|
663
|
+
persisted.agentFinishedAt = Date.now();
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (outcome === undefined) {
|
|
667
|
+
const workspaceDir = await inStage("environment", () => artifacts.exportWorkspace(runId, attemptId, scratchDir));
|
|
668
|
+
return await scoreAttempt(options, loadedEval, experiment, entry, { runId, attemptId, workspaceDir });
|
|
606
669
|
}
|
|
607
670
|
|
|
608
671
|
persisted.transcriptRef = await artifacts.writeTranscript(runId, attemptId, outcome.transcript);
|
|
@@ -614,7 +677,7 @@ async function runHostAttemptV3(
|
|
|
614
677
|
|
|
615
678
|
// Export FIRST, then score against the export (no live sandbox exists on
|
|
616
679
|
// the host path — exec runs in the exported workspace directory).
|
|
617
|
-
const workspaceDir = await artifacts.exportWorkspace(runId, attemptId, scratchDir);
|
|
680
|
+
const workspaceDir = await inStage("environment", () => artifacts.exportWorkspace(runId, attemptId, scratchDir));
|
|
618
681
|
return await scoreAttempt(options, loadedEval, experiment, entry, { runId, attemptId, workspaceDir, agentOutput: { finalReport: outcome.transcript.filter((e) => e.role === "assistant").at(-1)?.content ?? null, transcript: JSON.stringify(outcome.transcript).slice(0, 131072), truncated: JSON.stringify(outcome.transcript).length > 131072 } });
|
|
619
682
|
} finally {
|
|
620
683
|
await rm(scratchDir, { recursive: true, force: true });
|
|
@@ -682,7 +745,7 @@ async function runContainerAttemptV3(
|
|
|
682
745
|
const teardownFailure = await attemptRuntimeTeardown(runtime, undefined, provisionCtx, environment?.budgets.cleanupMs ?? options.budgets?.cleanupMs);
|
|
683
746
|
const error = teardownFailure !== undefined ? `${message}; ${teardownFailure}` : message;
|
|
684
747
|
await artifacts.writeError(runId, attemptId, error);
|
|
685
|
-
return { status: "error", error };
|
|
748
|
+
return { status: "error", error, errorKind: "environment" };
|
|
686
749
|
}
|
|
687
750
|
}
|
|
688
751
|
|
|
@@ -691,19 +754,22 @@ async function runContainerAttemptV3(
|
|
|
691
754
|
let disposition: AttemptDisposition;
|
|
692
755
|
try {
|
|
693
756
|
disposition = await (async (): Promise<AttemptDisposition> => {
|
|
694
|
-
sourceDir = await prepWorkspaceSource(loadedEval);
|
|
757
|
+
sourceDir = await inStage("environment", () => prepWorkspaceSource(loadedEval, agentAdapter?.startingState ?? "fixture"));
|
|
695
758
|
const resolvedImage = environment?.image;
|
|
696
759
|
const image = resolvedImage && resolvedImage.id !== "unresolved" ? resolvedImage.id : resolvedImage?.tag ?? runtime.image ?? (runtime.containerImage?.(loadedEval.metadata) as string);
|
|
697
760
|
if (environment?.egress.proxy) {
|
|
698
|
-
const
|
|
699
|
-
|
|
700
|
-
const
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
761
|
+
const egressPolicy = environment.egress;
|
|
762
|
+
await inStage("environment", async () => {
|
|
763
|
+
const networkName = await createRunNetwork(`${runId}-${attemptId}`);
|
|
764
|
+
try {
|
|
765
|
+
const sidecar = await startEgressSidecar({ runId, networkName, allowlist: egressPolicy.allowlist, image: EGRESS_PROXY_IMAGE });
|
|
766
|
+
egress = { networkName, sidecar };
|
|
767
|
+
} catch (err) { await removeRunNetwork(networkName); throw err; }
|
|
768
|
+
if (treatments.mcp?.sidecar) {
|
|
769
|
+
mcpNetwork = networkName;
|
|
770
|
+
mcpIp = await connectContainerNetwork(treatments.mcp.sidecar.id, networkName);
|
|
771
|
+
}
|
|
772
|
+
});
|
|
707
773
|
}
|
|
708
774
|
|
|
709
775
|
// --- treatment env: everything rides the 0600 env-file, never argv ---
|
|
@@ -748,17 +814,18 @@ async function runContainerAttemptV3(
|
|
|
748
814
|
Object.assign(env, proxyEnv);
|
|
749
815
|
}
|
|
750
816
|
|
|
751
|
-
const paths = await artifacts.prepareAttempt(runId, attemptId);
|
|
752
|
-
const
|
|
817
|
+
const paths = await inStage("environment", () => artifacts.prepareAttempt(runId, attemptId));
|
|
818
|
+
const fixtureDir = sourceDir;
|
|
819
|
+
const container = await inStage("environment", () => createAttemptContainer({
|
|
753
820
|
image,
|
|
754
821
|
env,
|
|
755
822
|
resultsDir: paths.attemptDir,
|
|
756
823
|
attemptId,
|
|
757
|
-
fixtureDir
|
|
824
|
+
fixtureDir,
|
|
758
825
|
// Under the egress treatment the internal network replaces the
|
|
759
826
|
// default bridge: the proxy sidecar is the only path to the world.
|
|
760
827
|
...(egress ? { network: egress.networkName } : {}),
|
|
761
|
-
});
|
|
828
|
+
}));
|
|
762
829
|
try {
|
|
763
830
|
let command: string[];
|
|
764
831
|
let baselineSha: string | undefined;
|
|
@@ -768,15 +835,17 @@ async function runContainerAttemptV3(
|
|
|
768
835
|
// final workspace against this exact commit. The SHA is recorded
|
|
769
836
|
// (not just HEAD) so an agent that makes its own commits cannot
|
|
770
837
|
// move the baseline out from under the diff.
|
|
771
|
-
const gitAvailable = (await container.execInWorkspace(["git", "--version"])).exitCode === 0;
|
|
838
|
+
const gitAvailable = await inStage("environment", async () => (await container.execInWorkspace(["git", "--version"])).exitCode === 0);
|
|
772
839
|
if (gitAvailable) {
|
|
773
|
-
await
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
840
|
+
baselineSha = await inStage("environment", async () => {
|
|
841
|
+
await mustExec(container, ["git", "init", "-q"]);
|
|
842
|
+
await mustExec(container, ["git", "add", "-A"]);
|
|
843
|
+
await mustExec(container, [
|
|
844
|
+
"git", "-c", "user.name=quickstudy", "-c", "user.email=quickstudy@localhost",
|
|
845
|
+
"commit", "-q", "--allow-empty", "-m", "quickstudy fixture baseline",
|
|
846
|
+
]);
|
|
847
|
+
return (await mustExec(container, ["git", "rev-parse", "HEAD"])).stdout.trim();
|
|
848
|
+
});
|
|
780
849
|
} else {
|
|
781
850
|
(options.log ?? console.log)(
|
|
782
851
|
`attempt ${attemptId}: git is unavailable in image "${image}" — diff.patch will be empty ` +
|
|
@@ -794,15 +863,23 @@ async function runContainerAttemptV3(
|
|
|
794
863
|
};
|
|
795
864
|
// Treatment config files (e.g. the MCP treatment's vendor config).
|
|
796
865
|
// The prompt itself is byte-identical across experiments.
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
866
|
+
await inStage("environment", async () => {
|
|
867
|
+
for (const [path, content] of Object.entries(agentAdapter.setupFiles(promptCtx))) {
|
|
868
|
+
await container.writeFile(path, content);
|
|
869
|
+
}
|
|
870
|
+
});
|
|
800
871
|
command = agentAdapter.command(promptCtx);
|
|
801
872
|
} else {
|
|
802
873
|
command = runtime.command as string[];
|
|
803
874
|
}
|
|
804
875
|
|
|
805
|
-
|
|
876
|
+
persisted.agentStartedAt = Date.now();
|
|
877
|
+
let agentResult: Awaited<ReturnType<typeof container.execInWorkspace>>;
|
|
878
|
+
try {
|
|
879
|
+
agentResult = await inStage("agent", () => container.execInWorkspace(command, { timeoutMs: args.attemptTimeoutMs }));
|
|
880
|
+
} finally {
|
|
881
|
+
persisted.agentFinishedAt = Date.now();
|
|
882
|
+
}
|
|
806
883
|
// The raw stream is the transcript artifact (redacted) — persisted
|
|
807
884
|
// even when the agent timed out or failed, so there is always evidence.
|
|
808
885
|
const redactedStream = redactSecrets(agentResult.stdout, secretEnv);
|
|
@@ -845,24 +922,34 @@ async function runContainerAttemptV3(
|
|
|
845
922
|
}
|
|
846
923
|
}
|
|
847
924
|
if (agentResult.timedOut) {
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
925
|
+
// Graded timeout: runDocker restarted the killed container, so the
|
|
926
|
+
// tree the agent left behind is exported and scored below.
|
|
927
|
+
// captureDiff already ran above.
|
|
928
|
+
await artifacts.writeError(runId, attemptId, `attempt timed out after ${args.attemptTimeoutMs}ms — container killed; workspace graded as left`);
|
|
929
|
+
persisted.limitHit = "timeout";
|
|
930
|
+
} else if (agentResult.exitCode !== 0) {
|
|
852
931
|
const message = `agent command exited ${agentResult.exitCode}\n--- stderr ---\n${agentResult.stderr}`;
|
|
853
932
|
await artifacts.writeError(runId, attemptId, redactSecrets(message, secretEnv));
|
|
854
|
-
return { status: "error", error: `agent command exited ${agentResult.exitCode}
|
|
933
|
+
return { status: "error", error: `agent command exited ${agentResult.exitCode}`, errorKind: "agent" };
|
|
934
|
+
} else {
|
|
935
|
+
await inStage("environment", captureDiff);
|
|
855
936
|
}
|
|
856
937
|
|
|
857
|
-
await captureDiff();
|
|
858
|
-
|
|
859
938
|
// Export the workspace BEFORE scoring, with the container still alive:
|
|
860
939
|
// file checks read the export, ctx.exec reaches into the live sandbox.
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
940
|
+
try {
|
|
941
|
+
await inStage("environment", () => deadline("export", environment?.budgets.exportMs ?? DEFAULT_BUDGETS.exportMs, async (signal) => {
|
|
942
|
+
const kill = () => { void container.teardown(); };
|
|
943
|
+
signal.addEventListener("abort", kill, { once: true });
|
|
944
|
+
try { await container.exportWorkspace(paths.workspace); } finally { signal.removeEventListener("abort", kill); }
|
|
945
|
+
}));
|
|
946
|
+
} catch (err) {
|
|
947
|
+
if (persisted.limitHit !== "timeout") throw err;
|
|
948
|
+
// Nothing to score: `incomplete` keeps its meaning "no workspace".
|
|
949
|
+
const error = `attempt timed out after ${args.attemptTimeoutMs}ms; workspace could not be exported`;
|
|
950
|
+
await artifacts.writeError(runId, attemptId, `${error}: ${err instanceof Error ? err.message : String(err)}`);
|
|
951
|
+
return { status: "incomplete", error };
|
|
952
|
+
}
|
|
866
953
|
return await scoreAttempt(options, loadedEval, experiment, entry, {
|
|
867
954
|
runId,
|
|
868
955
|
attemptId,
|
|
@@ -899,7 +986,7 @@ async function runContainerAttemptV3(
|
|
|
899
986
|
} catch (err) {
|
|
900
987
|
const message = err instanceof Error ? err.message : String(err);
|
|
901
988
|
await artifacts.writeError(runId, attemptId, message);
|
|
902
|
-
disposition = { status: "error", error: message };
|
|
989
|
+
disposition = { status: "error", error: message, errorKind: errorKindOf(err) };
|
|
903
990
|
} finally {
|
|
904
991
|
if (mcpNetwork && treatments.mcp?.sidecar) {
|
|
905
992
|
try { await disconnectContainerNetwork(treatments.mcp.sidecar.id, mcpNetwork); } catch { containerTeardownFailure = "MCP network disconnect failed"; }
|
|
@@ -983,7 +1070,7 @@ async function scoreAttempt(
|
|
|
983
1070
|
await options.artifacts.writeError(args.runId, args.attemptId, message);
|
|
984
1071
|
const judgments = (err as { judgments?: import("../evals/types.ts").JudgeRecord[] })?.judgments;
|
|
985
1072
|
const judgeRecords = judgments && validEvalResult({passed:false,checks:[],judgments}) ? judgments : undefined;
|
|
986
|
-
return { status: "error", error: message, ...(judgeRecords ? {judgeRecords} : {}) };
|
|
1073
|
+
return { status: "error", error: message, errorKind: "scorer", ...(judgeRecords ? {judgeRecords} : {}) };
|
|
987
1074
|
}
|
|
988
1075
|
}
|
|
989
1076
|
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `validate --oracle`: score each eval's reference solution (`solution/`
|
|
3
|
+
* overlaid on `local/`) on the host, with no agent and no runtime.
|
|
4
|
+
*
|
|
5
|
+
* `exec`, `query` and `getClient` are runtime-only here: each records that it
|
|
6
|
+
* was called and throws {@link RuntimeCapabilityError}. Tracking is by
|
|
7
|
+
* invocation on the parent side of the scorer worker (the worker RPCs every
|
|
8
|
+
* capability call back to this context), so a scorer that swallows the error
|
|
9
|
+
* is still reported as deferred. Evals that defer are proven by running an
|
|
10
|
+
* oracle experiment under the real runtime, which costs no model spend.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { rm } from "node:fs/promises";
|
|
14
|
+
import { DEFAULT_BUDGETS } from "../execution-policy.ts";
|
|
15
|
+
import { validEvalResult } from "../evals/result.ts";
|
|
16
|
+
import type { EvalExecResult, LoadedEval } from "../evals/types.ts";
|
|
17
|
+
import { buildEvalContext, RuntimeCapabilityError } from "./context.ts";
|
|
18
|
+
import { deadline, StageTimeoutError } from "./deadline.ts";
|
|
19
|
+
import { scoreInWorker } from "./score-worker.ts";
|
|
20
|
+
import { prepWorkspaceSource } from "./workspace-source.ts";
|
|
21
|
+
|
|
22
|
+
export type OracleOutcome =
|
|
23
|
+
| { kind: "pass"; ungradedFailures: string[] }
|
|
24
|
+
| { kind: "deferred"; capabilities: string[] }
|
|
25
|
+
| { kind: "failed"; failingChecks: string[]; error?: string }
|
|
26
|
+
| { kind: "missing" };
|
|
27
|
+
|
|
28
|
+
export interface ValidateOracleOptions {
|
|
29
|
+
/** Scoring deadline; defaults to the runner's scoring budget. */
|
|
30
|
+
timeoutMs?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const EXPERIMENT_ID = "validate-oracle";
|
|
34
|
+
|
|
35
|
+
/** Score one eval's reference solution; see the module doc for the outcomes. */
|
|
36
|
+
export async function validateOracle(loaded: LoadedEval, opts: ValidateOracleOptions = {}): Promise<OracleOutcome> {
|
|
37
|
+
if (loaded.solutionDir === null) return { kind: "missing" };
|
|
38
|
+
|
|
39
|
+
const used = new Set<"exec" | "query" | "getClient">();
|
|
40
|
+
const runtimeOnly = (capability: "exec" | "query" | "getClient"): never => {
|
|
41
|
+
used.add(capability);
|
|
42
|
+
throw new RuntimeCapabilityError(
|
|
43
|
+
capability,
|
|
44
|
+
`${capability}() is runtime-only under validate --oracle — run the oracle experiment under the real runtime`,
|
|
45
|
+
);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const workspaceDir = await prepWorkspaceSource(loaded, "solution");
|
|
49
|
+
try {
|
|
50
|
+
let result;
|
|
51
|
+
try {
|
|
52
|
+
result = await deadline("scoring", opts.timeoutMs ?? DEFAULT_BUDGETS.scoreMs, (signal) =>
|
|
53
|
+
scoreInWorker(
|
|
54
|
+
loaded.scorerPath,
|
|
55
|
+
buildEvalContext({
|
|
56
|
+
signal,
|
|
57
|
+
evalId: loaded.metadata.id,
|
|
58
|
+
experimentId: EXPERIMENT_ID,
|
|
59
|
+
trialIndex: 0,
|
|
60
|
+
...(loaded.metadata.framework !== undefined ? { framework: loaded.metadata.framework } : {}),
|
|
61
|
+
workspaceDir,
|
|
62
|
+
exec: async (): Promise<EvalExecResult> => runtimeOnly("exec"),
|
|
63
|
+
query: async () => runtimeOnly("query"),
|
|
64
|
+
getClient: () => runtimeOnly("getClient"),
|
|
65
|
+
}),
|
|
66
|
+
signal,
|
|
67
|
+
),
|
|
68
|
+
);
|
|
69
|
+
if (!validEvalResult(result)) throw new Error("scorer returned an invalid EvalResult");
|
|
70
|
+
} catch (err) {
|
|
71
|
+
// A scorer that runs past its deadline failed, whatever it touched first.
|
|
72
|
+
if (used.size > 0 && !(err instanceof StageTimeoutError)) return { kind: "deferred", capabilities: [...used].sort() };
|
|
73
|
+
return { kind: "failed", failingChecks: [], error: err instanceof Error ? err.message : String(err) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A scorer that reached for a runtime-only capability has not been proven
|
|
77
|
+
// here, even when it swallowed the error and returned passing checks.
|
|
78
|
+
if (used.size > 0) return { kind: "deferred", capabilities: [...used].sort() };
|
|
79
|
+
const graded = result.checks.filter((check) => check.graded !== false);
|
|
80
|
+
const failingGraded = graded.filter((check) => !check.passed).map((check) => check.name);
|
|
81
|
+
if (result.passed && failingGraded.length === 0) {
|
|
82
|
+
const ungradedFailures = result.checks.filter((check) => check.graded === false && !check.passed).map((check) => check.name);
|
|
83
|
+
return { kind: "pass", ungradedFailures };
|
|
84
|
+
}
|
|
85
|
+
return failingGraded.length > 0
|
|
86
|
+
? { kind: "failed", failingChecks: failingGraded }
|
|
87
|
+
: { kind: "failed", failingChecks: [], error: "scorer returned passed: false" };
|
|
88
|
+
} finally {
|
|
89
|
+
await rm(workspaceDir, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** One `validate --oracle` output line for an eval (without the leading bullet). */
|
|
94
|
+
export function formatOracleOutcome(evalId: string, outcome: OracleOutcome): string {
|
|
95
|
+
switch (outcome.kind) {
|
|
96
|
+
case "pass":
|
|
97
|
+
return outcome.ungradedFailures.length > 0
|
|
98
|
+
? `${evalId}: 1.0 (ungraded checks failing: ${outcome.ungradedFailures.join(", ")})`
|
|
99
|
+
: `${evalId}: 1.0`;
|
|
100
|
+
case "deferred":
|
|
101
|
+
return `${evalId}: deferred (scorer needs ${outcome.capabilities.join(", ")} — run the oracle experiment under the real runtime)`;
|
|
102
|
+
case "failed": {
|
|
103
|
+
const detail = outcome.failingChecks.length > 0 ? outcome.failingChecks.join(", ") : outcome.error ?? "scorer failed";
|
|
104
|
+
return `${evalId}: FAILED (${detail})`;
|
|
105
|
+
}
|
|
106
|
+
case "missing":
|
|
107
|
+
return `${evalId}: MISSING (no solution/)`;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace-source preparation shared by both runner paths (host and
|
|
3
|
+
* container) and by `validate --oracle`: build the attempt's starting tree in
|
|
4
|
+
* a fresh temp directory. The host path copies it into the attempt scratch
|
|
5
|
+
* dir; the container path `docker cp`s it whole into /workspace.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { cp, mkdtemp } from "node:fs/promises";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import type { StartingState } from "../adapters/types.ts";
|
|
12
|
+
import type { LoadedEval } from "../evals/types.ts";
|
|
13
|
+
|
|
14
|
+
export type { StartingState } from "../adapters/types.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Prepare the attempt's starting state: a copy of `local/`, or empty
|
|
18
|
+
* (tools-only evals). With `startingState: "solution"` the eval's
|
|
19
|
+
* `solution/` is then copied over it — solution files win; solutions are
|
|
20
|
+
* additive overlays and cannot delete fixture files.
|
|
21
|
+
*/
|
|
22
|
+
export async function prepWorkspaceSource(loadedEval: LoadedEval, startingState: StartingState = "fixture"): Promise<string> {
|
|
23
|
+
const sourceDir = await mkdtemp(join(tmpdir(), "quickstudy-v3-source-"));
|
|
24
|
+
if (loadedEval.localDir !== null) {
|
|
25
|
+
await cp(loadedEval.localDir, sourceDir, { recursive: true });
|
|
26
|
+
}
|
|
27
|
+
if (startingState === "solution") {
|
|
28
|
+
if (loadedEval.solutionDir === null) {
|
|
29
|
+
throw new Error(`eval "${loadedEval.metadata.id}" has no solution/ — the oracle adapter needs one`);
|
|
30
|
+
}
|
|
31
|
+
await cp(loadedEval.solutionDir, sourceDir, { recursive: true, force: true });
|
|
32
|
+
}
|
|
33
|
+
return sourceDir;
|
|
34
|
+
}
|
package/src/store/db.ts
CHANGED
|
@@ -18,6 +18,20 @@ import { SCHEMA_SQL } from "./schema.ts";
|
|
|
18
18
|
|
|
19
19
|
export type AttemptStatus = "completed" | "incomplete" | "error";
|
|
20
20
|
|
|
21
|
+
/** Which stage failed when an attempt's status is "error". */
|
|
22
|
+
export const ERROR_KINDS = ["environment", "agent", "scorer", "harness"] as const;
|
|
23
|
+
export type ErrorKind = (typeof ERROR_KINDS)[number];
|
|
24
|
+
/** Which limit an attempt hit; the tree it left behind was still graded. */
|
|
25
|
+
export type LimitKind = "timeout";
|
|
26
|
+
|
|
27
|
+
function toErrorKind(value: string | null): ErrorKind | null {
|
|
28
|
+
return value !== null && (ERROR_KINDS as readonly string[]).includes(value) ? (value as ErrorKind) : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function toLimitKind(value: string | null): LimitKind | null {
|
|
32
|
+
return value === "timeout" ? value : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
21
35
|
/** The run-level configuration recorded in `runs.config_json`. */
|
|
22
36
|
export interface RunConfig {
|
|
23
37
|
scheduler?: { concurrency: number; seed: string; policy: "seeded-hash-v1"; order: import("../plan.ts").AttemptPlan[] };
|
|
@@ -82,6 +96,13 @@ export interface NewAttempt {
|
|
|
82
96
|
startedAt?: number | null;
|
|
83
97
|
finishedAt?: number | null;
|
|
84
98
|
error?: string | null;
|
|
99
|
+
/** "timeout" = the agent hit its wall-clock limit and the tree it left was graded (column limit_hit). */
|
|
100
|
+
limitHit?: LimitKind | null;
|
|
101
|
+
/** Which stage failed; set only when status = "error". */
|
|
102
|
+
errorKind?: ErrorKind | null;
|
|
103
|
+
/** Agent phase only, epoch ms; null = not recorded. */
|
|
104
|
+
agentStartedAt?: number | null;
|
|
105
|
+
agentFinishedAt?: number | null;
|
|
85
106
|
}
|
|
86
107
|
|
|
87
108
|
export interface AttemptRecord {
|
|
@@ -106,6 +127,13 @@ export interface AttemptRecord {
|
|
|
106
127
|
startedAt: number | null;
|
|
107
128
|
finishedAt: number | null;
|
|
108
129
|
error: string | null;
|
|
130
|
+
/** "timeout" = the agent hit its wall-clock limit and the tree it left was graded (column limit_hit). */
|
|
131
|
+
limitHit?: LimitKind | null;
|
|
132
|
+
/** Which stage failed; set only when status = "error". */
|
|
133
|
+
errorKind?: ErrorKind | null;
|
|
134
|
+
/** Agent phase only, epoch ms; null = not recorded. */
|
|
135
|
+
agentStartedAt?: number | null;
|
|
136
|
+
agentFinishedAt?: number | null;
|
|
109
137
|
}
|
|
110
138
|
|
|
111
139
|
interface RunRow {
|
|
@@ -139,6 +167,10 @@ interface AttemptRow {
|
|
|
139
167
|
started_at: number | null;
|
|
140
168
|
finished_at: number | null;
|
|
141
169
|
error: string | null;
|
|
170
|
+
limit_hit: string | null;
|
|
171
|
+
error_kind: string | null;
|
|
172
|
+
agent_started_at: number | null;
|
|
173
|
+
agent_finished_at: number | null;
|
|
142
174
|
}
|
|
143
175
|
|
|
144
176
|
function toRunRecord(row: RunRow): RunRecord {
|
|
@@ -175,6 +207,10 @@ function toAttemptRecord(row: AttemptRow): AttemptRecord {
|
|
|
175
207
|
startedAt: row.started_at,
|
|
176
208
|
finishedAt: row.finished_at,
|
|
177
209
|
error: row.error,
|
|
210
|
+
limitHit: toLimitKind(row.limit_hit ?? null),
|
|
211
|
+
errorKind: toErrorKind(row.error_kind ?? null),
|
|
212
|
+
agentStartedAt: row.agent_started_at ?? null,
|
|
213
|
+
agentFinishedAt: row.agent_finished_at ?? null,
|
|
178
214
|
};
|
|
179
215
|
}
|
|
180
216
|
|
|
@@ -226,6 +262,21 @@ export class ResultsStore {
|
|
|
226
262
|
}
|
|
227
263
|
if (!attemptColumns.includes("judge_usage_json")) this.db.exec("ALTER TABLE attempts ADD COLUMN judge_usage_json TEXT;");
|
|
228
264
|
if (!attemptColumns.includes("retry_of")) this.db.exec("ALTER TABLE attempts ADD COLUMN retry_of TEXT;");
|
|
265
|
+
// Outcome taxonomy + agent-phase timing (nullable; NULL = unavailable).
|
|
266
|
+
for (const [name, type] of [
|
|
267
|
+
["limit_hit", "TEXT"],
|
|
268
|
+
["error_kind", "TEXT"],
|
|
269
|
+
["agent_started_at", "INTEGER"],
|
|
270
|
+
["agent_finished_at", "INTEGER"],
|
|
271
|
+
] as const) {
|
|
272
|
+
if (attemptColumns.includes(name)) continue;
|
|
273
|
+
try {
|
|
274
|
+
this.db.exec(`ALTER TABLE attempts ADD COLUMN ${name} ${type};`);
|
|
275
|
+
} catch (err) {
|
|
276
|
+
// A concurrent opener may have added it between the read and the ALTER.
|
|
277
|
+
if (!/duplicate column/i.test(err instanceof Error ? err.message : String(err))) throw err;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
229
280
|
this.db.exec("PRAGMA user_version = 3;");
|
|
230
281
|
}
|
|
231
282
|
|
|
@@ -264,8 +315,9 @@ export class ResultsStore {
|
|
|
264
315
|
`INSERT INTO attempts (
|
|
265
316
|
id, run_id, eval_id, experiment_id, suite, trial_index, attempt_identity_hash, status,
|
|
266
317
|
result_json, surface_usage_json, transcript_ref, diff_ref, cost_usd, tokens_in, tokens_out,
|
|
267
|
-
turns, started_at, finished_at, error, retry_of, judge_usage_json
|
|
268
|
-
|
|
318
|
+
turns, started_at, finished_at, error, retry_of, judge_usage_json,
|
|
319
|
+
limit_hit, error_kind, agent_started_at, agent_finished_at
|
|
320
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
269
321
|
)
|
|
270
322
|
.run(
|
|
271
323
|
attempt.id,
|
|
@@ -289,6 +341,10 @@ export class ResultsStore {
|
|
|
289
341
|
attempt.error ?? null,
|
|
290
342
|
attempt.retryOf ?? null,
|
|
291
343
|
attempt.judgeRecords ? JSON.stringify(attempt.judgeRecords) : null,
|
|
344
|
+
attempt.limitHit ?? null,
|
|
345
|
+
attempt.errorKind ?? null,
|
|
346
|
+
attempt.agentStartedAt ?? null,
|
|
347
|
+
attempt.agentFinishedAt ?? null,
|
|
292
348
|
);
|
|
293
349
|
}
|
|
294
350
|
|
package/src/store/schema.ts
CHANGED
|
@@ -32,7 +32,13 @@ CREATE TABLE IF NOT EXISTS attempts (
|
|
|
32
32
|
tokens_in INTEGER, tokens_out INTEGER,
|
|
33
33
|
turns INTEGER, -- agent steps; NULL = no adapter stream (scripted commands)
|
|
34
34
|
started_at INTEGER, finished_at INTEGER,
|
|
35
|
-
error TEXT
|
|
35
|
+
error TEXT, -- only when status = 'error'
|
|
36
|
+
-- Outcome taxonomy + agent-phase timing. NULL = unavailable / not applicable
|
|
37
|
+
-- (rows written before these columns existed read NULL; never backfilled).
|
|
38
|
+
limit_hit TEXT, -- 'timeout' when the agent hit its wall-clock limit and the tree it left was graded
|
|
39
|
+
error_kind TEXT, -- 'environment' | 'agent' | 'scorer' | 'harness' (status = 'error' only)
|
|
40
|
+
agent_started_at INTEGER, -- epoch ms; agent phase only
|
|
41
|
+
agent_finished_at INTEGER -- epoch ms; agent phase only
|
|
36
42
|
);
|
|
37
43
|
|
|
38
44
|
CREATE INDEX IF NOT EXISTS idx_attempts_run_pair ON attempts(run_id, eval_id, experiment_id);
|