@workos/quickstudy 0.3.0 → 0.5.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/echo.ts +2 -0
- package/src/adapters/oracle.ts +69 -0
- package/src/adapters/types.ts +27 -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 +136 -0
- package/src/report/report.ts +175 -8
- package/src/report/scorer-health.ts +129 -0
- package/src/runner/context.ts +19 -2
- package/src/runner/execute.ts +160 -61
- package/src/runner/oracle-validate.ts +109 -0
- package/src/runner/workspace-source.ts +34 -0
- package/src/store/db.ts +65 -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
|
|
@@ -449,6 +506,18 @@ export async function executeRunV3(options: ExecuteRunV3Options): Promise<RunSum
|
|
|
449
506
|
// ON THE RUN: the manifest holds runtime config only as an opaque hash,
|
|
450
507
|
// so the report could never recompute this after the fact.
|
|
451
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
|
+
),
|
|
452
521
|
...(offeredSurfaces !== undefined ? { surfaces: offeredSurfaces } : {}),
|
|
453
522
|
};
|
|
454
523
|
|
|
@@ -507,10 +576,14 @@ export async function executeRunV3(options: ExecuteRunV3Options): Promise<RunSum
|
|
|
507
576
|
// An attempt failure never aborts the run: persist the error and move on.
|
|
508
577
|
const message = err instanceof Error ? err.message : String(err);
|
|
509
578
|
await artifacts.writeError(runId, attemptId, message);
|
|
510
|
-
disposition = { status: "error", error: message };
|
|
579
|
+
disposition = { status: "error", error: message, errorKind: errorKindOf(err) };
|
|
511
580
|
}
|
|
512
581
|
|
|
513
582
|
persisted.status = disposition.status;
|
|
583
|
+
persisted.errorKind = disposition.status === "error" ? (disposition.errorKind ?? "harness") : null;
|
|
584
|
+
// A limit hit means the workspace it left was graded; an unscored
|
|
585
|
+
// attempt (incomplete, or a later stage errored) carries none.
|
|
586
|
+
if (disposition.status !== "completed") persisted.limitHit = null;
|
|
514
587
|
if (disposition.judgeRecords) persisted.judgeRecords = disposition.judgeRecords;
|
|
515
588
|
if (disposition.result !== undefined) persisted.result = disposition.result;
|
|
516
589
|
if (disposition.error !== undefined) persisted.error = disposition.error;
|
|
@@ -556,15 +629,6 @@ async function mustExec(
|
|
|
556
629
|
return result;
|
|
557
630
|
}
|
|
558
631
|
|
|
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
632
|
interface AttemptArgs {
|
|
569
633
|
runId: string;
|
|
570
634
|
attemptId: string;
|
|
@@ -585,10 +649,11 @@ async function runHostAttemptV3(
|
|
|
585
649
|
const { runId, attemptId, persisted } = args;
|
|
586
650
|
const adapter = hostAdapters[experiment.agent.adapter] as AgentAdapter;
|
|
587
651
|
|
|
588
|
-
const sourceDir = await prepWorkspaceSource(loadedEval);
|
|
589
|
-
const scratchDir = await mkdtemp(join(tmpdir(), "quickstudy-v3-attempt-"));
|
|
652
|
+
const sourceDir = await inStage("environment", () => prepWorkspaceSource(loadedEval, adapter.startingState ?? "fixture"));
|
|
653
|
+
const scratchDir = await inStage("environment", () => mkdtemp(join(tmpdir(), "quickstudy-v3-attempt-")));
|
|
590
654
|
try {
|
|
591
|
-
let outcome: AttemptOutcome;
|
|
655
|
+
let outcome: AttemptOutcome | undefined;
|
|
656
|
+
persisted.agentStartedAt = Date.now();
|
|
592
657
|
try {
|
|
593
658
|
outcome = await deadline("agent", args.attemptTimeoutMs, (signal) => adapter.runAttempt({
|
|
594
659
|
signal,
|
|
@@ -600,9 +665,19 @@ async function runHostAttemptV3(
|
|
|
600
665
|
trialIndex: entry.trialIndex,
|
|
601
666
|
}));
|
|
602
667
|
} catch (err) {
|
|
603
|
-
if (!(err instanceof StageTimeoutError)) throw err;
|
|
604
|
-
|
|
605
|
-
|
|
668
|
+
if (!(err instanceof StageTimeoutError)) throw new StageFailure("agent", err);
|
|
669
|
+
// Graded timeout: the adapter never returned (no transcript, diff, or
|
|
670
|
+
// metrics), but the scratch workspace it was writing into exists — the
|
|
671
|
+
// tree it left behind is exported and scored like any other.
|
|
672
|
+
await artifacts.writeError(runId, attemptId, `attempt timed out after ${args.attemptTimeoutMs}ms; workspace graded as left`);
|
|
673
|
+
persisted.limitHit = "timeout";
|
|
674
|
+
} finally {
|
|
675
|
+
persisted.agentFinishedAt = Date.now();
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (outcome === undefined) {
|
|
679
|
+
const workspaceDir = await inStage("environment", () => artifacts.exportWorkspace(runId, attemptId, scratchDir));
|
|
680
|
+
return await scoreAttempt(options, loadedEval, experiment, entry, { runId, attemptId, workspaceDir });
|
|
606
681
|
}
|
|
607
682
|
|
|
608
683
|
persisted.transcriptRef = await artifacts.writeTranscript(runId, attemptId, outcome.transcript);
|
|
@@ -614,7 +689,7 @@ async function runHostAttemptV3(
|
|
|
614
689
|
|
|
615
690
|
// Export FIRST, then score against the export (no live sandbox exists on
|
|
616
691
|
// the host path — exec runs in the exported workspace directory).
|
|
617
|
-
const workspaceDir = await artifacts.exportWorkspace(runId, attemptId, scratchDir);
|
|
692
|
+
const workspaceDir = await inStage("environment", () => artifacts.exportWorkspace(runId, attemptId, scratchDir));
|
|
618
693
|
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
694
|
} finally {
|
|
620
695
|
await rm(scratchDir, { recursive: true, force: true });
|
|
@@ -682,7 +757,7 @@ async function runContainerAttemptV3(
|
|
|
682
757
|
const teardownFailure = await attemptRuntimeTeardown(runtime, undefined, provisionCtx, environment?.budgets.cleanupMs ?? options.budgets?.cleanupMs);
|
|
683
758
|
const error = teardownFailure !== undefined ? `${message}; ${teardownFailure}` : message;
|
|
684
759
|
await artifacts.writeError(runId, attemptId, error);
|
|
685
|
-
return { status: "error", error };
|
|
760
|
+
return { status: "error", error, errorKind: "environment" };
|
|
686
761
|
}
|
|
687
762
|
}
|
|
688
763
|
|
|
@@ -691,19 +766,22 @@ async function runContainerAttemptV3(
|
|
|
691
766
|
let disposition: AttemptDisposition;
|
|
692
767
|
try {
|
|
693
768
|
disposition = await (async (): Promise<AttemptDisposition> => {
|
|
694
|
-
sourceDir = await prepWorkspaceSource(loadedEval);
|
|
769
|
+
sourceDir = await inStage("environment", () => prepWorkspaceSource(loadedEval, agentAdapter?.startingState ?? "fixture"));
|
|
695
770
|
const resolvedImage = environment?.image;
|
|
696
771
|
const image = resolvedImage && resolvedImage.id !== "unresolved" ? resolvedImage.id : resolvedImage?.tag ?? runtime.image ?? (runtime.containerImage?.(loadedEval.metadata) as string);
|
|
697
772
|
if (environment?.egress.proxy) {
|
|
698
|
-
const
|
|
699
|
-
|
|
700
|
-
const
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
773
|
+
const egressPolicy = environment.egress;
|
|
774
|
+
await inStage("environment", async () => {
|
|
775
|
+
const networkName = await createRunNetwork(`${runId}-${attemptId}`);
|
|
776
|
+
try {
|
|
777
|
+
const sidecar = await startEgressSidecar({ runId, networkName, allowlist: egressPolicy.allowlist, image: EGRESS_PROXY_IMAGE });
|
|
778
|
+
egress = { networkName, sidecar };
|
|
779
|
+
} catch (err) { await removeRunNetwork(networkName); throw err; }
|
|
780
|
+
if (treatments.mcp?.sidecar) {
|
|
781
|
+
mcpNetwork = networkName;
|
|
782
|
+
mcpIp = await connectContainerNetwork(treatments.mcp.sidecar.id, networkName);
|
|
783
|
+
}
|
|
784
|
+
});
|
|
707
785
|
}
|
|
708
786
|
|
|
709
787
|
// --- treatment env: everything rides the 0600 env-file, never argv ---
|
|
@@ -748,17 +826,18 @@ async function runContainerAttemptV3(
|
|
|
748
826
|
Object.assign(env, proxyEnv);
|
|
749
827
|
}
|
|
750
828
|
|
|
751
|
-
const paths = await artifacts.prepareAttempt(runId, attemptId);
|
|
752
|
-
const
|
|
829
|
+
const paths = await inStage("environment", () => artifacts.prepareAttempt(runId, attemptId));
|
|
830
|
+
const fixtureDir = sourceDir;
|
|
831
|
+
const container = await inStage("environment", () => createAttemptContainer({
|
|
753
832
|
image,
|
|
754
833
|
env,
|
|
755
834
|
resultsDir: paths.attemptDir,
|
|
756
835
|
attemptId,
|
|
757
|
-
fixtureDir
|
|
836
|
+
fixtureDir,
|
|
758
837
|
// Under the egress treatment the internal network replaces the
|
|
759
838
|
// default bridge: the proxy sidecar is the only path to the world.
|
|
760
839
|
...(egress ? { network: egress.networkName } : {}),
|
|
761
|
-
});
|
|
840
|
+
}));
|
|
762
841
|
try {
|
|
763
842
|
let command: string[];
|
|
764
843
|
let baselineSha: string | undefined;
|
|
@@ -768,15 +847,17 @@ async function runContainerAttemptV3(
|
|
|
768
847
|
// final workspace against this exact commit. The SHA is recorded
|
|
769
848
|
// (not just HEAD) so an agent that makes its own commits cannot
|
|
770
849
|
// move the baseline out from under the diff.
|
|
771
|
-
const gitAvailable = (await container.execInWorkspace(["git", "--version"])).exitCode === 0;
|
|
850
|
+
const gitAvailable = await inStage("environment", async () => (await container.execInWorkspace(["git", "--version"])).exitCode === 0);
|
|
772
851
|
if (gitAvailable) {
|
|
773
|
-
await
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
852
|
+
baselineSha = await inStage("environment", async () => {
|
|
853
|
+
await mustExec(container, ["git", "init", "-q"]);
|
|
854
|
+
await mustExec(container, ["git", "add", "-A"]);
|
|
855
|
+
await mustExec(container, [
|
|
856
|
+
"git", "-c", "user.name=quickstudy", "-c", "user.email=quickstudy@localhost",
|
|
857
|
+
"commit", "-q", "--allow-empty", "-m", "quickstudy fixture baseline",
|
|
858
|
+
]);
|
|
859
|
+
return (await mustExec(container, ["git", "rev-parse", "HEAD"])).stdout.trim();
|
|
860
|
+
});
|
|
780
861
|
} else {
|
|
781
862
|
(options.log ?? console.log)(
|
|
782
863
|
`attempt ${attemptId}: git is unavailable in image "${image}" — diff.patch will be empty ` +
|
|
@@ -794,15 +875,23 @@ async function runContainerAttemptV3(
|
|
|
794
875
|
};
|
|
795
876
|
// Treatment config files (e.g. the MCP treatment's vendor config).
|
|
796
877
|
// The prompt itself is byte-identical across experiments.
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
878
|
+
await inStage("environment", async () => {
|
|
879
|
+
for (const [path, content] of Object.entries(agentAdapter.setupFiles(promptCtx))) {
|
|
880
|
+
await container.writeFile(path, content);
|
|
881
|
+
}
|
|
882
|
+
});
|
|
800
883
|
command = agentAdapter.command(promptCtx);
|
|
801
884
|
} else {
|
|
802
885
|
command = runtime.command as string[];
|
|
803
886
|
}
|
|
804
887
|
|
|
805
|
-
|
|
888
|
+
persisted.agentStartedAt = Date.now();
|
|
889
|
+
let agentResult: Awaited<ReturnType<typeof container.execInWorkspace>>;
|
|
890
|
+
try {
|
|
891
|
+
agentResult = await inStage("agent", () => container.execInWorkspace(command, { timeoutMs: args.attemptTimeoutMs }));
|
|
892
|
+
} finally {
|
|
893
|
+
persisted.agentFinishedAt = Date.now();
|
|
894
|
+
}
|
|
806
895
|
// The raw stream is the transcript artifact (redacted) — persisted
|
|
807
896
|
// even when the agent timed out or failed, so there is always evidence.
|
|
808
897
|
const redactedStream = redactSecrets(agentResult.stdout, secretEnv);
|
|
@@ -845,24 +934,34 @@ async function runContainerAttemptV3(
|
|
|
845
934
|
}
|
|
846
935
|
}
|
|
847
936
|
if (agentResult.timedOut) {
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
937
|
+
// Graded timeout: runDocker restarted the killed container, so the
|
|
938
|
+
// tree the agent left behind is exported and scored below.
|
|
939
|
+
// captureDiff already ran above.
|
|
940
|
+
await artifacts.writeError(runId, attemptId, `attempt timed out after ${args.attemptTimeoutMs}ms — container killed; workspace graded as left`);
|
|
941
|
+
persisted.limitHit = "timeout";
|
|
942
|
+
} else if (agentResult.exitCode !== 0) {
|
|
852
943
|
const message = `agent command exited ${agentResult.exitCode}\n--- stderr ---\n${agentResult.stderr}`;
|
|
853
944
|
await artifacts.writeError(runId, attemptId, redactSecrets(message, secretEnv));
|
|
854
|
-
return { status: "error", error: `agent command exited ${agentResult.exitCode}
|
|
945
|
+
return { status: "error", error: `agent command exited ${agentResult.exitCode}`, errorKind: "agent" };
|
|
946
|
+
} else {
|
|
947
|
+
await inStage("environment", captureDiff);
|
|
855
948
|
}
|
|
856
949
|
|
|
857
|
-
await captureDiff();
|
|
858
|
-
|
|
859
950
|
// Export the workspace BEFORE scoring, with the container still alive:
|
|
860
951
|
// file checks read the export, ctx.exec reaches into the live sandbox.
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
952
|
+
try {
|
|
953
|
+
await inStage("environment", () => deadline("export", environment?.budgets.exportMs ?? DEFAULT_BUDGETS.exportMs, async (signal) => {
|
|
954
|
+
const kill = () => { void container.teardown(); };
|
|
955
|
+
signal.addEventListener("abort", kill, { once: true });
|
|
956
|
+
try { await container.exportWorkspace(paths.workspace); } finally { signal.removeEventListener("abort", kill); }
|
|
957
|
+
}));
|
|
958
|
+
} catch (err) {
|
|
959
|
+
if (persisted.limitHit !== "timeout") throw err;
|
|
960
|
+
// Nothing to score: `incomplete` keeps its meaning "no workspace".
|
|
961
|
+
const error = `attempt timed out after ${args.attemptTimeoutMs}ms; workspace could not be exported`;
|
|
962
|
+
await artifacts.writeError(runId, attemptId, `${error}: ${err instanceof Error ? err.message : String(err)}`);
|
|
963
|
+
return { status: "incomplete", error };
|
|
964
|
+
}
|
|
866
965
|
return await scoreAttempt(options, loadedEval, experiment, entry, {
|
|
867
966
|
runId,
|
|
868
967
|
attemptId,
|
|
@@ -899,7 +998,7 @@ async function runContainerAttemptV3(
|
|
|
899
998
|
} catch (err) {
|
|
900
999
|
const message = err instanceof Error ? err.message : String(err);
|
|
901
1000
|
await artifacts.writeError(runId, attemptId, message);
|
|
902
|
-
disposition = { status: "error", error: message };
|
|
1001
|
+
disposition = { status: "error", error: message, errorKind: errorKindOf(err) };
|
|
903
1002
|
} finally {
|
|
904
1003
|
if (mcpNetwork && treatments.mcp?.sidecar) {
|
|
905
1004
|
try { await disconnectContainerNetwork(treatments.mcp.sidecar.id, mcpNetwork); } catch { containerTeardownFailure = "MCP network disconnect failed"; }
|
|
@@ -983,7 +1082,7 @@ async function scoreAttempt(
|
|
|
983
1082
|
await options.artifacts.writeError(args.runId, args.attemptId, message);
|
|
984
1083
|
const judgments = (err as { judgments?: import("../evals/types.ts").JudgeRecord[] })?.judgments;
|
|
985
1084
|
const judgeRecords = judgments && validEvalResult({passed:false,checks:[],judgments}) ? judgments : undefined;
|
|
986
|
-
return { status: "error", error: message, ...(judgeRecords ? {judgeRecords} : {}) };
|
|
1085
|
+
return { status: "error", error: message, errorKind: "scorer", ...(judgeRecords ? {judgeRecords} : {}) };
|
|
987
1086
|
}
|
|
988
1087
|
}
|
|
989
1088
|
|
|
@@ -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
|
+
}
|