@workos/quickstudy 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +270 -0
- package/examples/harbor-notes/README.md +40 -0
- package/examples/harbor-notes/evals/create-note/EVAL.ts +14 -0
- package/examples/harbor-notes/evals/create-note/PROMPT.md +9 -0
- package/examples/harbor-notes/evals/create-note/local/README.txt +1 -0
- package/examples/harbor-notes/experiments/scripted.ts +6 -0
- package/examples/harbor-notes/package.json +6 -0
- package/examples/harbor-notes/quickstudy.identity.json +1 -0
- package/examples/harbor-notes/runtime.ts +48 -0
- package/examples/harbor-notes/semantic-example.ts +21 -0
- package/images/agent-runtime/Dockerfile +58 -0
- package/images/egress-proxy/Dockerfile +28 -0
- package/images/mcp-proxy/Dockerfile +30 -0
- package/package.json +53 -0
- package/src/adapters/claude.ts +107 -0
- package/src/adapters/codex.ts +107 -0
- package/src/adapters/echo.ts +57 -0
- package/src/adapters/parse.ts +117 -0
- package/src/adapters/types.ts +152 -0
- package/src/build-info.generated.ts +12 -0
- package/src/cli.ts +787 -0
- package/src/completeness.ts +104 -0
- package/src/diagnose/excerpt.ts +106 -0
- package/src/diagnose/prompt.ts +175 -0
- package/src/diagnose/render.ts +55 -0
- package/src/diagnose/run.ts +290 -0
- package/src/diagnose/select.ts +110 -0
- package/src/diagnose/types.ts +88 -0
- package/src/evals/discovery.ts +173 -0
- package/src/evals/prompt.ts +190 -0
- package/src/evals/result.ts +10 -0
- package/src/evals/types.ts +115 -0
- package/src/execution-policy.ts +71 -0
- package/src/experiments/discovery.ts +76 -0
- package/src/experiments/groups.ts +119 -0
- package/src/experiments/types.ts +116 -0
- package/src/export-types.ts +127 -0
- package/src/export.ts +381 -0
- package/src/hash.ts +74 -0
- package/src/identity-diff.ts +30 -0
- package/src/ids.ts +30 -0
- package/src/index.ts +58 -0
- package/src/isolation/docker.ts +639 -0
- package/src/isolation/image-contexts.generated.ts +927 -0
- package/src/isolation/images.ts +138 -0
- package/src/isolation/mcp-proxy/server.ts +260 -0
- package/src/isolation/mcp.ts +144 -0
- package/src/isolation/proxy/allowlist.ts +148 -0
- package/src/isolation/proxy/server.ts +382 -0
- package/src/llm.ts +132 -0
- package/src/manifest.ts +228 -0
- package/src/model-identity.ts +12 -0
- package/src/plan.ts +55 -0
- package/src/probe.ts +426 -0
- package/src/report/pass-at-k.ts +76 -0
- package/src/report/report.ts +731 -0
- package/src/runner/context.ts +96 -0
- package/src/runner/deadline.ts +37 -0
- package/src/runner/execute.ts +992 -0
- package/src/runner/run-lock.ts +32 -0
- package/src/runner/scheduler.ts +62 -0
- package/src/runner/score-worker.ts +107 -0
- package/src/runner/scorer-worker.ts +61 -0
- package/src/runtime/types.ts +89 -0
- package/src/secrets.ts +151 -0
- package/src/semantic.ts +185 -0
- package/src/serve.ts +52 -0
- package/src/source-identity.ts +76 -0
- package/src/store/artifacts.ts +146 -0
- package/src/store/db.ts +318 -0
- package/src/store/schema.ts +39 -0
- package/src/surface-usage.ts +297 -0
- package/src/ui-bundle.generated.ts +12 -0
- package/ui/dist/index.html +32 -0
|
@@ -0,0 +1,992 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The v3 attempt loop: workspace prep from the eval's `local/`, agent
|
|
3
|
+
* execution (host adapters, scripted container commands, or real container
|
|
4
|
+
* agent adapters — claude/codex — driven headless inside the attempt
|
|
5
|
+
* container), workspace export BEFORE scoring, scorer invocation with the
|
|
6
|
+
* sandbox still alive, and persistence with explicit
|
|
7
|
+
* `completed | incomplete | error` states.
|
|
8
|
+
*
|
|
9
|
+
* Treatments are runtime-owned: the experiment's `Runtime` supplies
|
|
10
|
+
* provisioning, the container image, egress hosts, MCP servers, and the
|
|
11
|
+
* PATH treatment (runtime/types.ts). The runner wires them — egress network
|
|
12
|
+
* + proxy sidecar are per attempt; the MCP auth sidecar is run-scoped. Provisioning is
|
|
13
|
+
* per-attempt, and every treatment reaches the container through the same
|
|
14
|
+
* 0600 env-file channel secrets use (never argv).
|
|
15
|
+
*
|
|
16
|
+
* Ordering invariant: for container runtimes the workspace is exported and
|
|
17
|
+
* the scorer runs while the container is still up — `ctx.exec` reaches into
|
|
18
|
+
* the live sandbox — and teardown happens last. Scoring against a dead
|
|
19
|
+
* sandbox is the failure mode this ordering exists to prevent. Teardown
|
|
20
|
+
* failures are recorded explicitly on the attempt, never silently swallowed
|
|
21
|
+
* and never allowed to mask the attempt's result.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { cp, mkdtemp, rm } from "node:fs/promises";
|
|
25
|
+
import { tmpdir } from "node:os";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import type {
|
|
28
|
+
AgentAdapter,
|
|
29
|
+
AttemptOutcome,
|
|
30
|
+
ContainerAgentAdapter,
|
|
31
|
+
ContainerAttemptContext,
|
|
32
|
+
} from "../adapters/types.ts";
|
|
33
|
+
import { ClaudeCodeAdapter } from "../adapters/claude.ts";
|
|
34
|
+
import { CodexAdapter } from "../adapters/codex.ts";
|
|
35
|
+
import { EchoAdapter } from "../adapters/echo.ts";
|
|
36
|
+
import { validEvalResult } from "../evals/result.ts";
|
|
37
|
+
import { importEvalScorer } from "../evals/discovery.ts";
|
|
38
|
+
import { deadline, StageTimeoutError } from "./deadline.ts";
|
|
39
|
+
import { scoreInWorker } from "./score-worker.ts";
|
|
40
|
+
import type { EvalResult, LoadedEval } from "../evals/types.ts";
|
|
41
|
+
import type { AgentSpec, Experiment, LoadedExperiment, Runtime } from "../experiments/types.ts";
|
|
42
|
+
import { canonicalJson } from "../identity-diff.ts";
|
|
43
|
+
import {
|
|
44
|
+
createAttemptContainer,
|
|
45
|
+
connectContainerNetwork,
|
|
46
|
+
disconnectContainerNetwork,
|
|
47
|
+
createRunNetwork,
|
|
48
|
+
inspectImageCommand,
|
|
49
|
+
inspectImageIdentity,
|
|
50
|
+
removeRunNetwork,
|
|
51
|
+
startEgressSidecar,
|
|
52
|
+
startMcpSidecar,
|
|
53
|
+
type EgressSidecar,
|
|
54
|
+
type McpSidecar,
|
|
55
|
+
} from "../isolation/docker.ts";
|
|
56
|
+
import { EGRESS_PROXY_IMAGE, MCP_PROXY_IMAGE } from "../isolation/images.ts";
|
|
57
|
+
import {
|
|
58
|
+
authedMcpServers,
|
|
59
|
+
persistRotatedRefreshToken,
|
|
60
|
+
readMcpAuthMaterialFromConfig,
|
|
61
|
+
rewriteMcpServerConfigs,
|
|
62
|
+
type McpServerConfig,
|
|
63
|
+
} from "../isolation/mcp.ts";
|
|
64
|
+
import { hashString } from "../hash.ts";
|
|
65
|
+
import { DEFAULT_BUDGETS, pairKey, resolvePairEnvironments, type LifecycleBudgets, type PairEnvironment } from "../execution-policy.ts";
|
|
66
|
+
import { ulid } from "../ids.ts";
|
|
67
|
+
import { attemptIdentityHash, buildRunManifest, type RunManifest, type RuntimeImageIdentity } from "../manifest.ts";
|
|
68
|
+
import { isMovingModelAlias } from "../model-identity.ts";
|
|
69
|
+
import type { AttemptPlan } from "../plan.ts";
|
|
70
|
+
import type { ProvisionedEnvironment, RuntimeProvisionContext } from "../runtime/types.ts";
|
|
71
|
+
import { collectProviderKeys, hostSecretEnv, redactSecrets, validateProviderKeys, type ProviderKeyProbe } from "../secrets.ts";
|
|
72
|
+
import { ArtifactsStore } from "../store/artifacts.ts";
|
|
73
|
+
import { activeAttempts, type NewAttempt, type ResultsStore, type RunConfig } from "../store/db.ts";
|
|
74
|
+
import { acquireRunLock } from "./run-lock.ts";
|
|
75
|
+
import { orderedPlan, schedule } from "./scheduler.ts";
|
|
76
|
+
import { compareGroups } from "../experiments/groups.ts";
|
|
77
|
+
import {
|
|
78
|
+
extractSurfaceUsage,
|
|
79
|
+
observationConfig,
|
|
80
|
+
surfaceUsageConfigFromRuntime,
|
|
81
|
+
surfaceUsageVendor,
|
|
82
|
+
type SurfaceUsageConfig,
|
|
83
|
+
} from "../surface-usage.ts";
|
|
84
|
+
import { buildEvalContext } from "./context.ts";
|
|
85
|
+
|
|
86
|
+
const DEFAULT_ATTEMPT_TIMEOUT_MS = 10 * 60_000;
|
|
87
|
+
|
|
88
|
+
/** The image-default PATH a `pathTreatment` directory is prepended to. */
|
|
89
|
+
const NORMAL_CONTAINER_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
|
90
|
+
|
|
91
|
+
export interface ExecuteRunV3Options {
|
|
92
|
+
evals: LoadedEval[];
|
|
93
|
+
experiments: LoadedExperiment[];
|
|
94
|
+
plan: AttemptPlan[];
|
|
95
|
+
store: ResultsStore;
|
|
96
|
+
artifacts: ArtifactsStore;
|
|
97
|
+
/** Prebuilt manifest (tests inject one); built from evals/experiments when omitted. */
|
|
98
|
+
manifest?: RunManifest;
|
|
99
|
+
/** Extra run-config fields recorded in `runs.config_json`. */
|
|
100
|
+
config?: Partial<RunConfig>;
|
|
101
|
+
/** Host adapters by name. Defaults to the built-in echo adapter. */
|
|
102
|
+
hostAdapters?: Record<string, AgentAdapter>;
|
|
103
|
+
/**
|
|
104
|
+
* Container agent adapter factories by name, constructed per experiment
|
|
105
|
+
* from its `agent` spec. Defaults to the built-in claude/codex adapters.
|
|
106
|
+
* A container experiment without a scripted `runtime.command` resolves its
|
|
107
|
+
* agent here; unknown names fail fast before any container work.
|
|
108
|
+
*/
|
|
109
|
+
containerAdapters?: Record<string, (agent: AgentSpec) => ContainerAgentAdapter>;
|
|
110
|
+
/**
|
|
111
|
+
* Route every container through its own allowlisting proxy and internal
|
|
112
|
+
* network. Unconfigured runtimes allow adapter endpoints only. Off by default.
|
|
113
|
+
*/
|
|
114
|
+
egressProxy?: boolean;
|
|
115
|
+
concurrency?: number;
|
|
116
|
+
seed?: string;
|
|
117
|
+
resumeRunId?: string;
|
|
118
|
+
retryUnfinished?: boolean;
|
|
119
|
+
signal?: AbortSignal;
|
|
120
|
+
attemptTimeoutMs?: number;
|
|
121
|
+
budgets?: Partial<LifecycleBudgets>;
|
|
122
|
+
log?: (line: string) => void;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface RunSummaryV3 {
|
|
126
|
+
runId: string;
|
|
127
|
+
total: number;
|
|
128
|
+
passed: number;
|
|
129
|
+
failed: number;
|
|
130
|
+
incomplete: number;
|
|
131
|
+
errors: number;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface AttemptDisposition {
|
|
135
|
+
judgeRecords?: import("../evals/types.ts").JudgeRecord[];
|
|
136
|
+
status: "completed" | "incomplete" | "error";
|
|
137
|
+
result?: EvalResult;
|
|
138
|
+
error?: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function defaultHostAdapters(): Record<string, AgentAdapter> {
|
|
142
|
+
const echo = new EchoAdapter();
|
|
143
|
+
return { [echo.name]: echo };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function defaultContainerAdapters(): Record<string, (agent: AgentSpec) => ContainerAgentAdapter> {
|
|
147
|
+
return {
|
|
148
|
+
claude: (agent) => new ClaudeCodeAdapter("claude", { model: agent.model, reasoning: agent.reasoning }),
|
|
149
|
+
codex: (agent) => new CodexAdapter("codex", { model: agent.model, reasoning: agent.reasoning }),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Resolve one container experiment's agent adapter, fail-fast on everything
|
|
155
|
+
* that would otherwise burn a container: unknown adapter names, unpinned
|
|
156
|
+
* model/reasoning (a moving default is not a valid measurement), and missing
|
|
157
|
+
* provider keys (checked by the caller via `requiredHostEnv`).
|
|
158
|
+
*/
|
|
159
|
+
function resolveContainerAdapter(
|
|
160
|
+
experiment: Experiment,
|
|
161
|
+
factories: Record<string, (agent: AgentSpec) => ContainerAgentAdapter>,
|
|
162
|
+
): ContainerAgentAdapter {
|
|
163
|
+
const make = Object.hasOwn(factories, experiment.agent.adapter) ? factories[experiment.agent.adapter] : undefined;
|
|
164
|
+
if (!make) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`experiment "${experiment.id}" has a container runtime with no runtime.command and no container agent ` +
|
|
167
|
+
`adapter named "${experiment.agent.adapter}" (available: ${Object.keys(factories).sort().join(", ")}) — ` +
|
|
168
|
+
`declare a scripted command or select a known adapter`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
const adapter = make(experiment.agent);
|
|
172
|
+
if (adapter.identity?.model === "UNPINNED") {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`experiment "${experiment.id}" must pin agent.model to an exact versioned id — ` +
|
|
175
|
+
`moving agent defaults are not valid measurements`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
if (adapter.identity?.model !== undefined && isMovingModelAlias(adapter.identity.model)) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`experiment "${experiment.id}" pins model "${adapter.identity.model}", which is a moving alias — ` +
|
|
181
|
+
`use an immutable versioned model id`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (adapter.identity?.reasoning === "UNPINNED") {
|
|
185
|
+
throw new Error(`experiment "${experiment.id}" must pin agent.reasoning so reasoning effort cannot drift between runs`);
|
|
186
|
+
}
|
|
187
|
+
return adapter;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Shared MCP authentication. Egress networks and allowlists belong to each attempt. */
|
|
191
|
+
interface TreatmentRuntimes {
|
|
192
|
+
mcp?: {
|
|
193
|
+
sidecar?: McpSidecar;
|
|
194
|
+
servers: Record<string, McpServerConfig>;
|
|
195
|
+
originalByExperiment: Map<string, Record<string, McpServerConfig>>;
|
|
196
|
+
serversByExperiment: Map<string, Record<string, McpServerConfig>>;
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function setupTreatmentRuntimes(args: {
|
|
201
|
+
declaredByExperiment: Map<string, Record<string, McpServerConfig>>;
|
|
202
|
+
runId: string;
|
|
203
|
+
}): Promise<TreatmentRuntimes> {
|
|
204
|
+
const merged: Record<string, McpServerConfig> = {};
|
|
205
|
+
for (const servers of args.declaredByExperiment.values()) for (const [name, spec] of Object.entries(servers)) {
|
|
206
|
+
if (merged[name] && canonicalJson(merged[name]) !== canonicalJson(spec)) throw new Error(`mcp server "${name}" has conflicting configs`);
|
|
207
|
+
merged[name] = spec;
|
|
208
|
+
}
|
|
209
|
+
if (args.declaredByExperiment.size === 0) return {};
|
|
210
|
+
let sidecar: McpSidecar | undefined;
|
|
211
|
+
if (Object.keys(authedMcpServers(merged)).length > 0) {
|
|
212
|
+
const material = readMcpAuthMaterialFromConfig(merged);
|
|
213
|
+
sidecar = await startMcpSidecar({ runId: args.runId, image: MCP_PROXY_IMAGE, upstreams: material.upstreams, refreshTokens: material.refreshTokens });
|
|
214
|
+
}
|
|
215
|
+
const base = sidecar ? `http://${sidecar.ip}:${sidecar.port}` : "http://unused";
|
|
216
|
+
return { mcp: { ...(sidecar ? { sidecar } : {}), servers: merged,
|
|
217
|
+
originalByExperiment: args.declaredByExperiment,
|
|
218
|
+
serversByExperiment: new Map([...args.declaredByExperiment].map(([id, servers]) => [id, rewriteMcpServerConfigs(servers, base)])),
|
|
219
|
+
} };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Stop the run's treatment infrastructure. Rotated MCP refresh tokens are
|
|
224
|
+
* read back BEFORE the sidecar dies — rotation makes the on-disk token stale
|
|
225
|
+
* the moment the sidecar refreshes once, and losing the live token silently
|
|
226
|
+
* would cost the operator a re-login. Teardown failures warn and continue
|
|
227
|
+
* (label-swept by `clean`); they never crash the run.
|
|
228
|
+
*/
|
|
229
|
+
async function teardownTreatmentRuntimes(state: TreatmentRuntimes, log: (line: string) => void): Promise<void> {
|
|
230
|
+
const mcpSidecar = state.mcp?.sidecar;
|
|
231
|
+
if (state.mcp && mcpSidecar) {
|
|
232
|
+
try {
|
|
233
|
+
const rotated = await mcpSidecar.readRotatedRefreshTokens();
|
|
234
|
+
for (const [name, token] of Object.entries(rotated)) {
|
|
235
|
+
const spec = state.mcp.servers[name];
|
|
236
|
+
if (token !== undefined && spec) persistRotatedRefreshToken(spec, token);
|
|
237
|
+
else {
|
|
238
|
+
log(
|
|
239
|
+
`warning: could not read back the rotated refresh token for mcp server "${name}" — ` +
|
|
240
|
+
`the next run may need a fresh login`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
await mcpSidecar.teardown();
|
|
245
|
+
} catch (err) {
|
|
246
|
+
log(
|
|
247
|
+
`warning: mcp auth proxy teardown failed (${err instanceof Error ? err.message : String(err)}) — ` +
|
|
248
|
+
`inspect resources labeled for this run before removing them`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Derive the OFFERED surface configuration per planned container experiment,
|
|
257
|
+
* for the run record (the egress-allowlist precedent: what a run offered is
|
|
258
|
+
* part of its record). MCP names come from the runner's already-resolved
|
|
259
|
+
* server maps — never from calling `runtime.mcpServers()` again, which could
|
|
260
|
+
* re-trigger credential checks. Experiments whose config yields nothing are
|
|
261
|
+
* omitted, and an all-empty result is `undefined` so the run record omits
|
|
262
|
+
* the field entirely (absent ≠ empty-offered).
|
|
263
|
+
*/
|
|
264
|
+
export function collectOfferedSurfaces(options: {
|
|
265
|
+
experimentIds: readonly string[];
|
|
266
|
+
experimentsById: Map<string, Experiment>;
|
|
267
|
+
mcpServersByExperiment?: Map<string, Record<string, McpServerConfig>>;
|
|
268
|
+
}): Record<string, SurfaceUsageConfig> | undefined {
|
|
269
|
+
const surfaces: Record<string, SurfaceUsageConfig> = {};
|
|
270
|
+
const ids = [...new Set(options.experimentIds)].sort((a, b) => a.localeCompare(b));
|
|
271
|
+
for (const experimentId of ids) {
|
|
272
|
+
const experiment = options.experimentsById.get(experimentId);
|
|
273
|
+
if (experiment === undefined || experiment.runtime.kind !== "container") continue;
|
|
274
|
+
const offered = surfaceUsageConfigFromRuntime(
|
|
275
|
+
experiment.runtime.config,
|
|
276
|
+
Object.keys(options.mcpServersByExperiment?.get(experimentId) ?? {}),
|
|
277
|
+
);
|
|
278
|
+
const declared =
|
|
279
|
+
(offered.docsHosts?.length ?? 0) + (offered.cliCommands?.length ?? 0) + (offered.mcpServers?.length ?? 0);
|
|
280
|
+
if (declared > 0 || experiment.runtime.observationTargets !== undefined) surfaces[experimentId] = offered;
|
|
281
|
+
}
|
|
282
|
+
return Object.keys(surfaces).length > 0 ? surfaces : undefined;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export async function executeRunV3(options: ExecuteRunV3Options): Promise<RunSummaryV3> {
|
|
286
|
+
const { store, artifacts, plan } = options;
|
|
287
|
+
const log = options.log ?? ((line: string) => console.log(line));
|
|
288
|
+
const attemptTimeoutMs = options.attemptTimeoutMs ?? options.budgets?.agentMs ?? DEFAULT_ATTEMPT_TIMEOUT_MS;
|
|
289
|
+
const hostAdapters = options.hostAdapters ?? defaultHostAdapters();
|
|
290
|
+
|
|
291
|
+
const evalsById = new Map(options.evals.map((entry) => [entry.metadata.id, entry]));
|
|
292
|
+
const experimentsById = new Map(options.experiments.map((entry) => [entry.experiment.id, entry.experiment]));
|
|
293
|
+
const containerAdapterFactories = options.containerAdapters ?? defaultContainerAdapters();
|
|
294
|
+
|
|
295
|
+
// Fail fast BEFORE the first attempt: every plan entry must resolve, every
|
|
296
|
+
// experiment must be executable (a scripted command or a known container
|
|
297
|
+
// agent adapter), and every provider key must be present on the host.
|
|
298
|
+
const containerAgents = new Map<string, ContainerAgentAdapter>();
|
|
299
|
+
const imageTagsByPair: Record<string, string> = {};
|
|
300
|
+
const declaredByExperiment = new Map<string, Record<string, McpServerConfig>>();
|
|
301
|
+
const imageTagsByExperiment = new Map<string, Set<string>>();
|
|
302
|
+
for (const entry of plan) {
|
|
303
|
+
if (!evalsById.has(entry.evalId)) throw new Error(`plan names unknown eval "${entry.evalId}"`);
|
|
304
|
+
const experiment = experimentsById.get(entry.experimentId);
|
|
305
|
+
if (!experiment) throw new Error(`plan names unknown experiment "${entry.experimentId}"`);
|
|
306
|
+
if (experiment.runtime.kind === "host" && !Object.hasOwn(hostAdapters, experiment.agent.adapter)) {
|
|
307
|
+
throw new Error(
|
|
308
|
+
`experiment "${experiment.id}" needs host adapter "${experiment.agent.adapter}" ` +
|
|
309
|
+
`(available: ${Object.keys(hostAdapters).join(", ")})`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
if (experiment.runtime.kind === "container") {
|
|
313
|
+
if ((experiment.runtime.command ?? []).length === 0 && !containerAgents.has(experiment.id)) {
|
|
314
|
+
const adapter = resolveContainerAdapter(experiment, containerAdapterFactories);
|
|
315
|
+
// Provider keys are validated here — before any run row, sidecar, or
|
|
316
|
+
// container exists — and injected into the attempt container at create.
|
|
317
|
+
collectProviderKeys([adapter]);
|
|
318
|
+
containerAgents.set(experiment.id, adapter);
|
|
319
|
+
}
|
|
320
|
+
if (experiment.runtime.mcpServers && !declaredByExperiment.has(experiment.id)) declaredByExperiment.set(experiment.id, experiment.runtime.mcpServers());
|
|
321
|
+
const loadedEval = evalsById.get(entry.evalId) as LoadedEval;
|
|
322
|
+
const key = pairKey(entry.evalId, entry.experimentId);
|
|
323
|
+
const image = imageTagsByPair[key] ?? experiment.runtime.image ?? experiment.runtime.containerImage?.(loadedEval.metadata);
|
|
324
|
+
if (image === undefined || image === "") {
|
|
325
|
+
throw new Error(
|
|
326
|
+
`experiment "${experiment.id}" resolves no container image for eval "${entry.evalId}" — ` +
|
|
327
|
+
`set runtime.image or a containerImage(metadata) resolver`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
let tags = imageTagsByExperiment.get(experiment.id);
|
|
331
|
+
if (tags === undefined) imageTagsByExperiment.set(experiment.id, (tags = new Set()));
|
|
332
|
+
tags.add(image);
|
|
333
|
+
imageTagsByPair[key] = image;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Live key preflight: a key that is present but rejected by the provider
|
|
338
|
+
// would burn a container per attempt just to 401 inside it. Probe each
|
|
339
|
+
// unique agent's endpoint once — before any docker, sidecar, or run-row
|
|
340
|
+
// work — and say plainly whether the run starts. Adapters without a probe
|
|
341
|
+
// (tests, custom adapters) skip it.
|
|
342
|
+
const probesByAdapter = new Map<string, ProviderKeyProbe>();
|
|
343
|
+
for (const adapter of containerAgents.values()) {
|
|
344
|
+
if (adapter.keyProbe === undefined || probesByAdapter.has(adapter.name)) continue;
|
|
345
|
+
probesByAdapter.set(adapter.name, {
|
|
346
|
+
name: adapter.name,
|
|
347
|
+
envVars: adapter.requiredHostEnv,
|
|
348
|
+
...adapter.keyProbe(collectProviderKeys([adapter])),
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
await validateProviderKeys([...probesByAdapter.values()], { log });
|
|
352
|
+
log(`run starting: ${plan.length} attempt(s)`);
|
|
353
|
+
|
|
354
|
+
// Resolve image digests and agent CLI versions BEFORE building the manifest:
|
|
355
|
+
// a mutable tag names the image, its digest is the image, and the executable
|
|
356
|
+
// in it is the CLI version the run actually measured. Probing happens only
|
|
357
|
+
// when this call builds the manifest itself (injected manifests are the
|
|
358
|
+
// tests' deterministic path); host-only plans collect no tags and never
|
|
359
|
+
// touch Docker.
|
|
360
|
+
let manifest = options.manifest;
|
|
361
|
+
if (manifest === undefined) {
|
|
362
|
+
const allTags = [...new Set([...imageTagsByExperiment.values()].flatMap((tags) => [...tags]))].sort((a, b) =>
|
|
363
|
+
a.localeCompare(b),
|
|
364
|
+
);
|
|
365
|
+
const images: Record<string, RuntimeImageIdentity> = {};
|
|
366
|
+
for (const tag of allTags) {
|
|
367
|
+
// A missing image fails here — before the run row, sidecars, or any
|
|
368
|
+
// agent spend exist — with docker's own "No such image" message.
|
|
369
|
+
images[tag] = await inspectImageIdentity(tag);
|
|
370
|
+
}
|
|
371
|
+
const cliVersions: Record<string, string> = {};
|
|
372
|
+
const probeCache = new Map<string, string>();
|
|
373
|
+
for (const [experimentId, adapter] of containerAgents) {
|
|
374
|
+
const command = adapter.versionCommand;
|
|
375
|
+
const tags = imageTagsByExperiment.get(experimentId);
|
|
376
|
+
if (command === undefined || tags === undefined) continue;
|
|
377
|
+
// A resolver can map one experiment to several images; they share the
|
|
378
|
+
// agent CLI layer, so probe the first (sorted) tag for determinism.
|
|
379
|
+
const tag = [...tags].sort((a, b) => a.localeCompare(b))[0] as string;
|
|
380
|
+
const cacheKey = `${tag}\0${command.join(" ")}`;
|
|
381
|
+
let version = probeCache.get(cacheKey);
|
|
382
|
+
if (version === undefined) {
|
|
383
|
+
try {
|
|
384
|
+
version = await inspectImageCommand(tag, command);
|
|
385
|
+
} catch (err) {
|
|
386
|
+
// A failed probe degrades to the experiment's declared cliVersion
|
|
387
|
+
// rather than aborting: the headless invocation may still work
|
|
388
|
+
// where a bare --entrypoint probe does not.
|
|
389
|
+
log(
|
|
390
|
+
`warning: version probe "${command.join(" ")}" failed in image "${tag}" ` +
|
|
391
|
+
`(${err instanceof Error ? err.message : String(err)}) — recording the declared cliVersion`,
|
|
392
|
+
);
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
probeCache.set(cacheKey, version);
|
|
396
|
+
}
|
|
397
|
+
cliVersions[experimentId] = version;
|
|
398
|
+
}
|
|
399
|
+
const scorerIdentities: Record<string, Record<string, unknown>> = {};
|
|
400
|
+
for (const loaded of options.evals) {
|
|
401
|
+
const scorer = await importEvalScorer(loaded.scorerPath);
|
|
402
|
+
if (scorer.identity) scorerIdentities[loaded.metadata.id] = scorer.identity;
|
|
403
|
+
}
|
|
404
|
+
const pairs = resolvePairEnvironments({ ...options, images, imageTags: imageTagsByPair,
|
|
405
|
+
adapterHosts: Object.fromEntries([...containerAgents].map(([id, adapter]) => [id, adapter.egressHosts ?? []])),
|
|
406
|
+
budgets: { ...options.budgets, agentMs: options.attemptTimeoutMs ?? options.budgets?.agentMs ?? attemptTimeoutMs },
|
|
407
|
+
mcpHashes: Object.fromEntries([...declaredByExperiment].map(([id, servers]) => [id, hashString(canonicalJson(servers))])),
|
|
408
|
+
});
|
|
409
|
+
manifest = buildRunManifest({ evals: options.evals, experiments: options.experiments, images, cliVersions, pairs, scorerIdentities });
|
|
410
|
+
}
|
|
411
|
+
const recorded = options.resumeRunId ? store.getRun(options.resumeRunId) : null;
|
|
412
|
+
if (options.resumeRunId && !recorded) throw new Error(`cannot resume unknown run ${options.resumeRunId}`);
|
|
413
|
+
if (recorded && (recorded.manifest.manifestVersion !== "v4" || recorded.manifestHash !== manifest.hash)) throw new Error("resume refused: code or environment identity changed (historical v3 identity cannot be resumed)");
|
|
414
|
+
const runId = recorded?.id ?? ulid();
|
|
415
|
+
const concurrency = options.concurrency ?? recorded?.config.scheduler?.concurrency ?? 1;
|
|
416
|
+
const seed = options.seed ?? recorded?.config.scheduler?.seed ?? "0";
|
|
417
|
+
const order = orderedPlan(plan, seed);
|
|
418
|
+
if (recorded && canonicalJson(recorded.config.scheduler?.order) !== canonicalJson(order)) throw new Error("resume refused: planned coordinates or ordering changed");
|
|
419
|
+
if (!Number.isInteger(concurrency) || concurrency < 1) throw new Error("concurrency must be a positive integer");
|
|
420
|
+
const releaseLock = acquireRunLock(artifacts.rootDir, runId);
|
|
421
|
+
let treatments: TreatmentRuntimes = {};
|
|
422
|
+
try {
|
|
423
|
+
|
|
424
|
+
// Treatment infrastructure comes up BEFORE the run row: setup is fail-fast
|
|
425
|
+
// (missing MCP credentials, invalid egress hosts), and what a run could
|
|
426
|
+
// reach is part of its recorded configuration.
|
|
427
|
+
treatments = await setupTreatmentRuntimes({ declaredByExperiment, runId });
|
|
428
|
+
|
|
429
|
+
try {
|
|
430
|
+
// Offered surfaces are recorded ON THE RUN — the per-attempt derivation
|
|
431
|
+
// (observed-usage extraction) is otherwise derived live and discarded,
|
|
432
|
+
// which would leave post-hoc diagnosis blind in exactly the
|
|
433
|
+
// never-invoked-the-surface case.
|
|
434
|
+
const offeredSurfaces = collectOfferedSurfaces({
|
|
435
|
+
experimentIds: plan.map((entry) => entry.experimentId),
|
|
436
|
+
experimentsById,
|
|
437
|
+
...(treatments.mcp !== undefined ? { mcpServersByExperiment: treatments.mcp.serversByExperiment } : {}),
|
|
438
|
+
});
|
|
439
|
+
const config: RunConfig = {
|
|
440
|
+
scheduler: { concurrency, seed, policy: "seeded-hash-v1", order },
|
|
441
|
+
trials: options.config?.trials ?? plan.reduce((max, entry) => Math.max(max, entry.trialIndex + 1), 1),
|
|
442
|
+
evalIds: options.config?.evalIds ?? [...new Set(plan.map((entry) => entry.evalId))],
|
|
443
|
+
experimentIds: options.config?.experimentIds ?? [...new Set(plan.map((entry) => entry.experimentId))],
|
|
444
|
+
...(options.config?.evalsRoot !== undefined ? { evalsRoot: options.config.evalsRoot } : {}),
|
|
445
|
+
...(options.config?.experimentsRoot !== undefined ? { experimentsRoot: options.config.experimentsRoot } : {}),
|
|
446
|
+
// Summary only. Each attempt enforces its manifest.pairs policy.
|
|
447
|
+
egress: { proxy: options.egressProxy ?? false, allowlist: [...new Set(Object.values(manifest.pairs ?? {}).flatMap((p) => p.egress.proxy ? p.egress.allowlist : []))].sort() },
|
|
448
|
+
// Comparison groups (with their withhold-and-diff state) are recorded
|
|
449
|
+
// ON THE RUN: the manifest holds runtime config only as an opaque hash,
|
|
450
|
+
// so the report could never recompute this after the fact.
|
|
451
|
+
groups: compareGroups(options.experiments.map((entry) => entry.experiment), manifest),
|
|
452
|
+
...(offeredSurfaces !== undefined ? { surfaces: offeredSurfaces } : {}),
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
const summary: RunSummaryV3 = { runId, total: plan.length, passed: 0, failed: 0, incomplete: 0, errors: 0 };
|
|
456
|
+
|
|
457
|
+
const previous = activeAttempts(store.listAttempts(runId));
|
|
458
|
+
const coordinate = (entry: AttemptPlan) => JSON.stringify([entry.evalId, entry.experimentId, entry.trialIndex]);
|
|
459
|
+
const byCoordinate = new Map<string, typeof previous>();
|
|
460
|
+
for (const row of previous) {
|
|
461
|
+
const key = coordinate(row); const rows = byCoordinate.get(key) ?? []; rows.push(row); byCoordinate.set(key, rows);
|
|
462
|
+
if (row.status === "completed" && !validEvalResult(row.result)) throw new Error(`resume refused: invalid completed outcome ${row.id}`);
|
|
463
|
+
if (rows.length > 1) throw new Error(`resume refused: duplicate trial coordinate ${key}`);
|
|
464
|
+
if (row.attemptIdentityHash !== attemptIdentityHash(row, manifest)) throw new Error(`resume refused: attempt identity mismatch ${row.id}`);
|
|
465
|
+
}
|
|
466
|
+
if (recorded) store.reopenRun(runId);
|
|
467
|
+
else store.insertRun({ id: runId, startedAt: Date.now(), config, manifest });
|
|
468
|
+
const pending = order.filter((entry) => {
|
|
469
|
+
const existing = byCoordinate.get(coordinate(entry))?.[0];
|
|
470
|
+
return !existing || (existing.status !== "completed" && options.retryUnfinished);
|
|
471
|
+
});
|
|
472
|
+
const ids = new Map(plan.map((entry) => [coordinate(entry), ulid()]));
|
|
473
|
+
await schedule(pending, concurrency, async (entry) => {
|
|
474
|
+
const loadedEval = evalsById.get(entry.evalId) as LoadedEval;
|
|
475
|
+
const experiment = experimentsById.get(entry.experimentId) as Experiment;
|
|
476
|
+
const attemptId = ids.get(coordinate(entry))!;
|
|
477
|
+
const startedAt = Date.now();
|
|
478
|
+
|
|
479
|
+
const persisted: NewAttempt = {
|
|
480
|
+
id: attemptId,
|
|
481
|
+
retryOf: byCoordinate.get(coordinate(entry))?.[0]?.id ?? null,
|
|
482
|
+
runId,
|
|
483
|
+
evalId: entry.evalId,
|
|
484
|
+
experimentId: entry.experimentId,
|
|
485
|
+
suite: loadedEval.metadata.suite,
|
|
486
|
+
trialIndex: entry.trialIndex,
|
|
487
|
+
attemptIdentityHash: attemptIdentityHash(entry, manifest),
|
|
488
|
+
status: "error",
|
|
489
|
+
startedAt,
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
let disposition: AttemptDisposition;
|
|
493
|
+
try {
|
|
494
|
+
disposition =
|
|
495
|
+
experiment.runtime.kind === "host"
|
|
496
|
+
? await runHostAttemptV3(options, hostAdapters, loadedEval, experiment, entry, { runId, attemptId, attemptTimeoutMs, persisted })
|
|
497
|
+
: await runContainerAttemptV3(
|
|
498
|
+
options,
|
|
499
|
+
loadedEval,
|
|
500
|
+
experiment,
|
|
501
|
+
entry,
|
|
502
|
+
{ runId, attemptId, attemptTimeoutMs, persisted, environment: manifest.pairs?.[pairKey(entry.evalId, entry.experimentId)] },
|
|
503
|
+
treatments,
|
|
504
|
+
containerAgents.get(experiment.id),
|
|
505
|
+
);
|
|
506
|
+
} catch (err) {
|
|
507
|
+
// An attempt failure never aborts the run: persist the error and move on.
|
|
508
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
509
|
+
await artifacts.writeError(runId, attemptId, message);
|
|
510
|
+
disposition = { status: "error", error: message };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
persisted.status = disposition.status;
|
|
514
|
+
if (disposition.judgeRecords) persisted.judgeRecords = disposition.judgeRecords;
|
|
515
|
+
if (disposition.result !== undefined) persisted.result = disposition.result;
|
|
516
|
+
if (disposition.error !== undefined) persisted.error = disposition.error;
|
|
517
|
+
persisted.finishedAt = Date.now();
|
|
518
|
+
store.insertAttempt(persisted);
|
|
519
|
+
|
|
520
|
+
if (disposition.status === "completed") {
|
|
521
|
+
if (disposition.result?.passed) summary.passed += 1;
|
|
522
|
+
else summary.failed += 1;
|
|
523
|
+
} else if (disposition.status === "incomplete") {
|
|
524
|
+
summary.incomplete += 1;
|
|
525
|
+
} else {
|
|
526
|
+
summary.errors += 1;
|
|
527
|
+
}
|
|
528
|
+
log(
|
|
529
|
+
`attempt ${attemptId} (${entry.evalId} × ${entry.experimentId} trial ${entry.trialIndex + 1}): ` +
|
|
530
|
+
`${disposition.status}${disposition.result ? ` — ${disposition.result.passed ? "passed" : "failed"}` : ""}`,
|
|
531
|
+
);
|
|
532
|
+
}, { ...(options.signal ? { signal: options.signal } : {}), capacity: (entry) => experimentsById.get(entry.experimentId)?.runtime.capacity });
|
|
533
|
+
|
|
534
|
+
const current = activeAttempts(store.listAttempts(runId));
|
|
535
|
+
summary.passed = current.filter((a) => a.status === "completed" && a.result?.passed).length;
|
|
536
|
+
summary.failed = current.filter((a) => a.status === "completed" && !a.result?.passed).length;
|
|
537
|
+
summary.errors = current.filter((a) => a.status === "error").length;
|
|
538
|
+
summary.incomplete = current.filter((a) => a.status === "incomplete").length + plan.length - current.length;
|
|
539
|
+
if (!options.signal?.aborted) store.finishRun(runId, Date.now());
|
|
540
|
+
return summary;
|
|
541
|
+
} finally {
|
|
542
|
+
await teardownTreatmentRuntimes(treatments, log);
|
|
543
|
+
}
|
|
544
|
+
} finally { releaseLock(); }
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Exec inside the container and throw (with stderr) on non-zero exit. */
|
|
548
|
+
async function mustExec(
|
|
549
|
+
container: { execInWorkspace(cmd: string[], opts?: { timeoutMs?: number }): Promise<{ exitCode: number; stdout: string; stderr: string }> },
|
|
550
|
+
cmd: string[],
|
|
551
|
+
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
|
552
|
+
const result = await container.execInWorkspace(cmd);
|
|
553
|
+
if (result.exitCode !== 0) {
|
|
554
|
+
throw new Error(`container command "${cmd.join(" ")}" exited ${result.exitCode}: ${result.stderr.trim()}`);
|
|
555
|
+
}
|
|
556
|
+
return result;
|
|
557
|
+
}
|
|
558
|
+
|
|
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
|
+
interface AttemptArgs {
|
|
569
|
+
runId: string;
|
|
570
|
+
attemptId: string;
|
|
571
|
+
attemptTimeoutMs: number;
|
|
572
|
+
persisted: NewAttempt;
|
|
573
|
+
environment?: PairEnvironment;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async function runHostAttemptV3(
|
|
577
|
+
options: ExecuteRunV3Options,
|
|
578
|
+
hostAdapters: Record<string, AgentAdapter>,
|
|
579
|
+
loadedEval: LoadedEval,
|
|
580
|
+
experiment: Experiment,
|
|
581
|
+
entry: AttemptPlan,
|
|
582
|
+
args: AttemptArgs,
|
|
583
|
+
): Promise<AttemptDisposition> {
|
|
584
|
+
const { artifacts } = options;
|
|
585
|
+
const { runId, attemptId, persisted } = args;
|
|
586
|
+
const adapter = hostAdapters[experiment.agent.adapter] as AgentAdapter;
|
|
587
|
+
|
|
588
|
+
const sourceDir = await prepWorkspaceSource(loadedEval);
|
|
589
|
+
const scratchDir = await mkdtemp(join(tmpdir(), "quickstudy-v3-attempt-"));
|
|
590
|
+
try {
|
|
591
|
+
let outcome: AttemptOutcome;
|
|
592
|
+
try {
|
|
593
|
+
outcome = await deadline("agent", args.attemptTimeoutMs, (signal) => adapter.runAttempt({
|
|
594
|
+
signal,
|
|
595
|
+
runId,
|
|
596
|
+
attemptId,
|
|
597
|
+
fixtureDir: sourceDir,
|
|
598
|
+
workspaceDir: scratchDir,
|
|
599
|
+
prompt: loadedEval.promptBody,
|
|
600
|
+
trialIndex: entry.trialIndex,
|
|
601
|
+
}));
|
|
602
|
+
} catch (err) {
|
|
603
|
+
if (!(err instanceof StageTimeoutError)) throw err;
|
|
604
|
+
await artifacts.writeError(runId, attemptId, `attempt timed out after ${args.attemptTimeoutMs}ms`);
|
|
605
|
+
return { status: "incomplete", error: `attempt timed out after ${args.attemptTimeoutMs}ms` };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
persisted.transcriptRef = await artifacts.writeTranscript(runId, attemptId, outcome.transcript);
|
|
609
|
+
persisted.diffRef = await artifacts.writeDiff(runId, attemptId, outcome.diff);
|
|
610
|
+
persisted.tokensIn = outcome.metrics.tokensIn;
|
|
611
|
+
persisted.tokensOut = outcome.metrics.tokensOut;
|
|
612
|
+
persisted.turns = outcome.metrics.turns;
|
|
613
|
+
if (outcome.metrics.costUsd !== undefined) persisted.costUsd = outcome.metrics.costUsd;
|
|
614
|
+
|
|
615
|
+
// Export FIRST, then score against the export (no live sandbox exists on
|
|
616
|
+
// the host path — exec runs in the exported workspace directory).
|
|
617
|
+
const workspaceDir = await artifacts.exportWorkspace(runId, attemptId, scratchDir);
|
|
618
|
+
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
|
+
} finally {
|
|
620
|
+
await rm(scratchDir, { recursive: true, force: true });
|
|
621
|
+
if (sourceDir) await rm(sourceDir, { recursive: true, force: true });
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Run the provisioned (or runtime-level) teardown, returning a failure note
|
|
627
|
+
* instead of throwing: a broken teardown must never mask the attempt's
|
|
628
|
+
* result — the note is recorded explicitly on the attempt instead.
|
|
629
|
+
*/
|
|
630
|
+
async function attemptRuntimeTeardown(
|
|
631
|
+
runtime: Runtime,
|
|
632
|
+
provisioned: ProvisionedEnvironment | undefined,
|
|
633
|
+
ctx: RuntimeProvisionContext,
|
|
634
|
+
timeoutMs = DEFAULT_BUDGETS.cleanupMs,
|
|
635
|
+
): Promise<string | undefined> {
|
|
636
|
+
try {
|
|
637
|
+
await deadline("cleanup", timeoutMs, async (signal) => {
|
|
638
|
+
if (provisioned?.teardown) await provisioned.teardown(signal);
|
|
639
|
+
else if (runtime.teardown) await runtime.teardown({ ...ctx, signal });
|
|
640
|
+
});
|
|
641
|
+
return undefined;
|
|
642
|
+
} catch (err) {
|
|
643
|
+
return `runtime teardown failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
async function runContainerAttemptV3(
|
|
648
|
+
options: ExecuteRunV3Options,
|
|
649
|
+
loadedEval: LoadedEval,
|
|
650
|
+
experiment: Experiment,
|
|
651
|
+
entry: AttemptPlan,
|
|
652
|
+
args: AttemptArgs,
|
|
653
|
+
treatments: TreatmentRuntimes,
|
|
654
|
+
agentAdapter: ContainerAgentAdapter | undefined,
|
|
655
|
+
): Promise<AttemptDisposition> {
|
|
656
|
+
const { artifacts } = options;
|
|
657
|
+
const { runId, attemptId, persisted } = args;
|
|
658
|
+
const runtime = experiment.runtime;
|
|
659
|
+
const environment = args.environment;
|
|
660
|
+
let egress: { networkName: string; sidecar: EgressSidecar } | undefined;
|
|
661
|
+
let mcpIp: string | undefined;
|
|
662
|
+
let mcpNetwork: string | undefined;
|
|
663
|
+
|
|
664
|
+
const provisionCtx: RuntimeProvisionContext = {
|
|
665
|
+
attemptId,
|
|
666
|
+
evalId: entry.evalId,
|
|
667
|
+
metadata: loadedEval.metadata,
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
// Provision BEFORE any container work: a failed provision is an explicit
|
|
671
|
+
// attempt error with the provisioner's message and costs no agent spend.
|
|
672
|
+
let provisioned: ProvisionedEnvironment | undefined;
|
|
673
|
+
if (runtime.provision) {
|
|
674
|
+
try {
|
|
675
|
+
provisioned = await deadline("provision", environment?.budgets.provisionMs ?? options.budgets?.provisionMs ?? DEFAULT_BUDGETS.provisionMs,
|
|
676
|
+
(signal) => runtime.provision!({ ...provisionCtx, signal }),
|
|
677
|
+
async (late) => { const note = await attemptRuntimeTeardown(runtime, late, provisionCtx, options.budgets?.cleanupMs); if (note) await artifacts.writeError(runId, attemptId, note); });
|
|
678
|
+
} catch (err) {
|
|
679
|
+
const message = `provision failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
680
|
+
// Teardown is still attempted — a provisioner may fail after creating
|
|
681
|
+
// some of what its runtime-level teardown knows how to scrub.
|
|
682
|
+
const teardownFailure = await attemptRuntimeTeardown(runtime, undefined, provisionCtx, environment?.budgets.cleanupMs ?? options.budgets?.cleanupMs);
|
|
683
|
+
const error = teardownFailure !== undefined ? `${message}; ${teardownFailure}` : message;
|
|
684
|
+
await artifacts.writeError(runId, attemptId, error);
|
|
685
|
+
return { status: "error", error };
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
let sourceDir: Awaited<ReturnType<typeof prepWorkspaceSource>> | undefined;
|
|
690
|
+
let containerTeardownFailure: string | undefined;
|
|
691
|
+
let disposition: AttemptDisposition;
|
|
692
|
+
try {
|
|
693
|
+
disposition = await (async (): Promise<AttemptDisposition> => {
|
|
694
|
+
sourceDir = await prepWorkspaceSource(loadedEval);
|
|
695
|
+
const resolvedImage = environment?.image;
|
|
696
|
+
const image = resolvedImage && resolvedImage.id !== "unresolved" ? resolvedImage.id : resolvedImage?.tag ?? runtime.image ?? (runtime.containerImage?.(loadedEval.metadata) as string);
|
|
697
|
+
if (environment?.egress.proxy) {
|
|
698
|
+
const networkName = await createRunNetwork(`${runId}-${attemptId}`);
|
|
699
|
+
try {
|
|
700
|
+
const sidecar = await startEgressSidecar({ runId, networkName, allowlist: environment.egress.allowlist, image: EGRESS_PROXY_IMAGE });
|
|
701
|
+
egress = { networkName, sidecar };
|
|
702
|
+
} catch (err) { await removeRunNetwork(networkName); throw err; }
|
|
703
|
+
if (treatments.mcp?.sidecar) {
|
|
704
|
+
mcpNetwork = networkName;
|
|
705
|
+
mcpIp = await connectContainerNetwork(treatments.mcp.sidecar.id, networkName);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// --- treatment env: everything rides the 0600 env-file, never argv ---
|
|
710
|
+
const env: Record<string, string> = { ...provisioned?.env };
|
|
711
|
+
// Only the SECRET-bearing subset feeds redaction: provisioned
|
|
712
|
+
// credentials and provider keys. Treatment plumbing (PATH, proxy env,
|
|
713
|
+
// the credential-free MCP server map) is diagnostic, not secret —
|
|
714
|
+
// redacting it would blind the transcript reader for no hygiene gain.
|
|
715
|
+
const secretEnv: Record<string, string> = { ...provisioned?.env };
|
|
716
|
+
const pathDir = environment?.pathTreatment ?? undefined;
|
|
717
|
+
if (pathDir !== undefined && pathDir !== "") {
|
|
718
|
+
// Only this treatment exposes the directory; other treatments keep
|
|
719
|
+
// the image-default PATH and cannot accidentally discover its tools.
|
|
720
|
+
env["PATH"] = `${pathDir}:${NORMAL_CONTAINER_PATH}`;
|
|
721
|
+
}
|
|
722
|
+
const originalServers = treatments.mcp?.originalByExperiment.get(experiment.id);
|
|
723
|
+
const rewrittenServers = mcpIp && originalServers && treatments.mcp?.sidecar
|
|
724
|
+
? rewriteMcpServerConfigs(originalServers, `http://${mcpIp}:${treatments.mcp.sidecar.port}`)
|
|
725
|
+
: treatments.mcp?.serversByExperiment.get(experiment.id);
|
|
726
|
+
if (rewrittenServers !== undefined) {
|
|
727
|
+
// Credential-free by construction: rewriteMcpServerConfigs stripped
|
|
728
|
+
// every auth block and pointed authed servers at the proxy sidecar.
|
|
729
|
+
env["QUICKSTUDY_MCP_SERVERS"] = JSON.stringify(rewrittenServers);
|
|
730
|
+
}
|
|
731
|
+
if (agentAdapter !== undefined) {
|
|
732
|
+
// The agent's provider keys, validated at run preflight — injected
|
|
733
|
+
// at create via the 0600 env-file channel, never argv.
|
|
734
|
+
const providerKeys = collectProviderKeys([agentAdapter]);
|
|
735
|
+
Object.assign(env, providerKeys);
|
|
736
|
+
Object.assign(secretEnv, providerKeys);
|
|
737
|
+
}
|
|
738
|
+
if (egress) {
|
|
739
|
+
const proxyEnv = egress.sidecar.proxyEnv();
|
|
740
|
+
const mcpSidecar = treatments.mcp?.sidecar;
|
|
741
|
+
if (mcpSidecar) {
|
|
742
|
+
// The mcp sidecar shares the internal network — its address must
|
|
743
|
+
// bypass the egress proxy, which refuses raw-IP targets by design.
|
|
744
|
+
const noProxy = `${proxyEnv["NO_PROXY"]},${mcpIp ?? mcpSidecar.ip}`;
|
|
745
|
+
proxyEnv["NO_PROXY"] = noProxy;
|
|
746
|
+
proxyEnv["no_proxy"] = noProxy;
|
|
747
|
+
}
|
|
748
|
+
Object.assign(env, proxyEnv);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const paths = await artifacts.prepareAttempt(runId, attemptId);
|
|
752
|
+
const container = await createAttemptContainer({
|
|
753
|
+
image,
|
|
754
|
+
env,
|
|
755
|
+
resultsDir: paths.attemptDir,
|
|
756
|
+
attemptId,
|
|
757
|
+
fixtureDir: sourceDir,
|
|
758
|
+
// Under the egress treatment the internal network replaces the
|
|
759
|
+
// default bridge: the proxy sidecar is the only path to the world.
|
|
760
|
+
...(egress ? { network: egress.networkName } : {}),
|
|
761
|
+
});
|
|
762
|
+
try {
|
|
763
|
+
let command: string[];
|
|
764
|
+
let baselineSha: string | undefined;
|
|
765
|
+
if (agentAdapter !== undefined) {
|
|
766
|
+
// --- real agent path: baseline commit -> treatment files -> CLI ---
|
|
767
|
+
// Baseline commit: the diff artifact is git's comparison of the
|
|
768
|
+
// final workspace against this exact commit. The SHA is recorded
|
|
769
|
+
// (not just HEAD) so an agent that makes its own commits cannot
|
|
770
|
+
// move the baseline out from under the diff.
|
|
771
|
+
const gitAvailable = (await container.execInWorkspace(["git", "--version"])).exitCode === 0;
|
|
772
|
+
if (gitAvailable) {
|
|
773
|
+
await mustExec(container, ["git", "init", "-q"]);
|
|
774
|
+
await mustExec(container, ["git", "add", "-A"]);
|
|
775
|
+
await mustExec(container, [
|
|
776
|
+
"git", "-c", "user.name=quickstudy", "-c", "user.email=quickstudy@localhost",
|
|
777
|
+
"commit", "-q", "--allow-empty", "-m", "quickstudy fixture baseline",
|
|
778
|
+
]);
|
|
779
|
+
baselineSha = (await mustExec(container, ["git", "rev-parse", "HEAD"])).stdout.trim();
|
|
780
|
+
} else {
|
|
781
|
+
(options.log ?? console.log)(
|
|
782
|
+
`attempt ${attemptId}: git is unavailable in image "${image}" — diff.patch will be empty ` +
|
|
783
|
+
`(the agent-runtime image ships git; only bare test images lack it)`,
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const promptCtx: ContainerAttemptContext = {
|
|
788
|
+
runId,
|
|
789
|
+
attemptId,
|
|
790
|
+
prompt: loadedEval.promptBody,
|
|
791
|
+
trialIndex: entry.trialIndex,
|
|
792
|
+
...(rewrittenServers !== undefined ? { mcpServers: rewrittenServers } : {}),
|
|
793
|
+
webPolicy: environment?.webPolicy ?? "native-web-blocked",
|
|
794
|
+
};
|
|
795
|
+
// Treatment config files (e.g. the MCP treatment's vendor config).
|
|
796
|
+
// The prompt itself is byte-identical across experiments.
|
|
797
|
+
for (const [path, content] of Object.entries(agentAdapter.setupFiles(promptCtx))) {
|
|
798
|
+
await container.writeFile(path, content);
|
|
799
|
+
}
|
|
800
|
+
command = agentAdapter.command(promptCtx);
|
|
801
|
+
} else {
|
|
802
|
+
command = runtime.command as string[];
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const agentResult = await container.execInWorkspace(command, { timeoutMs: args.attemptTimeoutMs });
|
|
806
|
+
// The raw stream is the transcript artifact (redacted) — persisted
|
|
807
|
+
// even when the agent timed out or failed, so there is always evidence.
|
|
808
|
+
const redactedStream = redactSecrets(agentResult.stdout, secretEnv);
|
|
809
|
+
persisted.transcriptRef = await artifacts.writeTranscriptRaw(runId, attemptId, redactedStream);
|
|
810
|
+
if (agentAdapter !== undefined) {
|
|
811
|
+
const metrics = agentAdapter.parseStream(agentResult.stdout);
|
|
812
|
+
persisted.tokensIn = metrics.tokensIn;
|
|
813
|
+
persisted.tokensOut = metrics.tokensOut;
|
|
814
|
+
persisted.turns = metrics.turns;
|
|
815
|
+
if (metrics.costUsd !== undefined) persisted.costUsd = metrics.costUsd;
|
|
816
|
+
// Surface-usage telemetry: extracted from the already-redacted
|
|
817
|
+
// stream (only sanitized identifiers are stored). Offered surfaces
|
|
818
|
+
// derive from the runtime's config + resolved MCP server names. An
|
|
819
|
+
// unsupported vendor stream stays NULL — never a zero-filled object.
|
|
820
|
+
const vendor = surfaceUsageVendor(experiment.agent.adapter);
|
|
821
|
+
if (vendor !== null) {
|
|
822
|
+
persisted.surfaceUsage = extractSurfaceUsage(
|
|
823
|
+
vendor,
|
|
824
|
+
redactedStream,
|
|
825
|
+
observationConfig(surfaceUsageConfigFromRuntime(runtime.config, Object.keys(rewrittenServers ?? {})), runtime.observationTargets),
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
if (agentResult.timedOut) {
|
|
830
|
+
await artifacts.writeError(runId, attemptId, `attempt timed out after ${args.attemptTimeoutMs}ms — container removed`);
|
|
831
|
+
return { status: "incomplete", error: `attempt timed out after ${args.attemptTimeoutMs}ms` };
|
|
832
|
+
}
|
|
833
|
+
if (agentResult.exitCode !== 0) {
|
|
834
|
+
const message = `agent command exited ${agentResult.exitCode}\n--- stderr ---\n${agentResult.stderr}`;
|
|
835
|
+
await artifacts.writeError(runId, attemptId, redactSecrets(message, secretEnv));
|
|
836
|
+
return { status: "error", error: `agent command exited ${agentResult.exitCode}` };
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
if (baselineSha !== undefined) {
|
|
840
|
+
// `git add -A` stages new files so the diff captures them; diffing
|
|
841
|
+
// the recorded baseline SHA (not HEAD) also captures any commits
|
|
842
|
+
// the agent made itself.
|
|
843
|
+
await mustExec(container, ["git", "add", "-A"]);
|
|
844
|
+
const diff = (await mustExec(container, ["git", "diff", baselineSha])).stdout;
|
|
845
|
+
persisted.diffRef = await artifacts.writeDiff(runId, attemptId, redactSecrets(diff, secretEnv));
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// Export the workspace BEFORE scoring, with the container still alive:
|
|
849
|
+
// file checks read the export, ctx.exec reaches into the live sandbox.
|
|
850
|
+
await deadline("export", environment?.budgets.exportMs ?? DEFAULT_BUDGETS.exportMs, async (signal) => {
|
|
851
|
+
const kill = () => { void container.teardown(); };
|
|
852
|
+
signal.addEventListener("abort", kill, { once: true });
|
|
853
|
+
try { await container.exportWorkspace(paths.workspace); } finally { signal.removeEventListener("abort", kill); }
|
|
854
|
+
});
|
|
855
|
+
return await scoreAttempt(options, loadedEval, experiment, entry, {
|
|
856
|
+
runId,
|
|
857
|
+
attemptId,
|
|
858
|
+
workspaceDir: paths.workspace,
|
|
859
|
+
agentOutput: extractAgentOutput(redactedStream),
|
|
860
|
+
exec: (cmd, opts) => container.execInWorkspace(cmd, opts),
|
|
861
|
+
cancel: () => container.teardown(),
|
|
862
|
+
...(provisioned?.env ? { provisionedEnv: provisioned.env } : {}),
|
|
863
|
+
});
|
|
864
|
+
} finally {
|
|
865
|
+
// Teardown LAST — after export and scoring both resolved. A teardown
|
|
866
|
+
// failure is captured, never thrown: it must not mask the result.
|
|
867
|
+
if (egress) {
|
|
868
|
+
try {
|
|
869
|
+
const lines = (await egress.sidecar.logs()).split("\n").flatMap((line) => {
|
|
870
|
+
try {
|
|
871
|
+
const event = JSON.parse(line);
|
|
872
|
+
if (event.type !== "egress" || event.decision !== "denied") return [];
|
|
873
|
+
// Only hostname-level identifiers survive: never raw requests or query strings.
|
|
874
|
+
const host = typeof event.host === "string" && /^[a-zA-Z0-9.*:[\]_-]+$/.test(event.host) ? event.host.slice(0, 253) : "invalid-host";
|
|
875
|
+
return [JSON.stringify({ type: "egress", attempt_id: attemptId, host, decision: "denied", reason: event.reason })];
|
|
876
|
+
} catch { return []; }
|
|
877
|
+
});
|
|
878
|
+
await artifacts.writeEgressDenials(runId, attemptId, lines);
|
|
879
|
+
} catch (err) { containerTeardownFailure = `egress evidence unavailable: ${err instanceof Error ? err.message : String(err)}`; }
|
|
880
|
+
}
|
|
881
|
+
try {
|
|
882
|
+
await container.teardown();
|
|
883
|
+
} catch (err) {
|
|
884
|
+
containerTeardownFailure = `container teardown failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
})();
|
|
888
|
+
} catch (err) {
|
|
889
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
890
|
+
await artifacts.writeError(runId, attemptId, message);
|
|
891
|
+
disposition = { status: "error", error: message };
|
|
892
|
+
} finally {
|
|
893
|
+
if (mcpNetwork && treatments.mcp?.sidecar) {
|
|
894
|
+
try { await disconnectContainerNetwork(treatments.mcp.sidecar.id, mcpNetwork); } catch { containerTeardownFailure = "MCP network disconnect failed"; }
|
|
895
|
+
}
|
|
896
|
+
if (egress) {
|
|
897
|
+
try { await egress.sidecar.teardown(); } catch { containerTeardownFailure = "egress sidecar teardown failed"; }
|
|
898
|
+
try { await removeRunNetwork(egress.networkName); } catch { containerTeardownFailure = "egress network teardown failed"; }
|
|
899
|
+
}
|
|
900
|
+
if (sourceDir) await rm(sourceDir, { recursive: true, force: true });
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// Explicit teardown accounting: the attempt keeps its status and result;
|
|
904
|
+
// any teardown failure is appended to its recorded error, never thrown.
|
|
905
|
+
const failures: string[] = [];
|
|
906
|
+
if (containerTeardownFailure !== undefined) failures.push(containerTeardownFailure);
|
|
907
|
+
const provisionTeardownFailure = await attemptRuntimeTeardown(runtime, provisioned, provisionCtx, environment?.budgets.cleanupMs ?? options.budgets?.cleanupMs);
|
|
908
|
+
if (provisionTeardownFailure !== undefined) failures.push(provisionTeardownFailure);
|
|
909
|
+
if (failures.length > 0) {
|
|
910
|
+
const note = failures.join("; ");
|
|
911
|
+
const error = disposition.error !== undefined && disposition.error !== "" ? `${disposition.error}; ${note}` : note;
|
|
912
|
+
await artifacts.writeError(runId, attemptId, error);
|
|
913
|
+
disposition = { ...disposition, error };
|
|
914
|
+
}
|
|
915
|
+
return disposition;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
async function scoreAttempt(
|
|
919
|
+
options: ExecuteRunV3Options,
|
|
920
|
+
loadedEval: LoadedEval,
|
|
921
|
+
experiment: Experiment,
|
|
922
|
+
entry: AttemptPlan,
|
|
923
|
+
args: {
|
|
924
|
+
runId: string;
|
|
925
|
+
attemptId: string;
|
|
926
|
+
workspaceDir: string;
|
|
927
|
+
exec?: (cmd: string[], opts?: { timeoutMs?: number }) => Promise<{ exitCode: number; stdout: string; stderr: string; timedOut: boolean }>;
|
|
928
|
+
/** The provisioned env, when the runtime provisioned this attempt. */
|
|
929
|
+
provisionedEnv?: Record<string, string>;
|
|
930
|
+
cancel?: () => Promise<void>;
|
|
931
|
+
agentOutput?: import("../evals/types.ts").AgentOutput;
|
|
932
|
+
},
|
|
933
|
+
): Promise<AttemptDisposition> {
|
|
934
|
+
try {
|
|
935
|
+
return await deadline("scoring", options.budgets?.scoreMs ?? DEFAULT_BUDGETS.scoreMs, async (signal) => {
|
|
936
|
+
const cancel = () => { void args.cancel?.(); };
|
|
937
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
938
|
+
try {
|
|
939
|
+
// Runtime-provided capabilities join the scorer's context here; a helper
|
|
940
|
+
// the runtime does not provide throws at call time, naming the
|
|
941
|
+
// capability and the experiment (context.ts).
|
|
942
|
+
const capabilities =
|
|
943
|
+
experiment.runtime.scorerCapabilities?.({
|
|
944
|
+
signal,
|
|
945
|
+
attemptId: args.attemptId,
|
|
946
|
+
evalId: entry.evalId,
|
|
947
|
+
experimentId: entry.experimentId,
|
|
948
|
+
metadata: loadedEval.metadata,
|
|
949
|
+
env: args.provisionedEnv ?? {},
|
|
950
|
+
}) ?? {};
|
|
951
|
+
const result = await scoreInWorker(loadedEval.scorerPath,
|
|
952
|
+
buildEvalContext({
|
|
953
|
+
signal,
|
|
954
|
+
...(args.agentOutput ? { agentOutput: args.agentOutput } : {}),
|
|
955
|
+
redact: async (text) => redactSecrets(redactSecrets(text, hostSecretEnv()), args.provisionedEnv ?? {}),
|
|
956
|
+
evalId: entry.evalId,
|
|
957
|
+
experimentId: entry.experimentId,
|
|
958
|
+
trialIndex: entry.trialIndex,
|
|
959
|
+
...(loadedEval.metadata.framework !== undefined ? { framework: loadedEval.metadata.framework } : {}),
|
|
960
|
+
workspaceDir: args.workspaceDir,
|
|
961
|
+
...(args.exec ? { exec: args.exec } : {}),
|
|
962
|
+
...(capabilities.query ? { query: capabilities.query } : {}),
|
|
963
|
+
...(capabilities.getClient ? { getClient: capabilities.getClient } : {}),
|
|
964
|
+
}), signal,
|
|
965
|
+
);
|
|
966
|
+
if (!validEvalResult(result)) throw new Error("scorer returned an invalid EvalResult");
|
|
967
|
+
return { status: "completed", result };
|
|
968
|
+
} finally { signal.removeEventListener("abort", cancel); }
|
|
969
|
+
});
|
|
970
|
+
} catch (err) {
|
|
971
|
+
const message = redactSecrets(redactSecrets(`scorer threw: ${err instanceof Error ? err.message : String(err)}`, hostSecretEnv()), args.provisionedEnv ?? {});
|
|
972
|
+
await options.artifacts.writeError(args.runId, args.attemptId, message);
|
|
973
|
+
const judgments = (err as { judgments?: import("../evals/types.ts").JudgeRecord[] })?.judgments;
|
|
974
|
+
const judgeRecords = judgments && validEvalResult({passed:false,checks:[],judgments}) ? judgments : undefined;
|
|
975
|
+
return { status: "error", error: message, ...(judgeRecords ? {judgeRecords} : {}) };
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/** Supported stream shapes only; unavailable final reports stay null. */
|
|
980
|
+
function extractAgentOutput(raw: string): import("../evals/types.ts").AgentOutput {
|
|
981
|
+
let finalReport: string | null = null;
|
|
982
|
+
for (const line of raw.split("\n")) {
|
|
983
|
+
try {
|
|
984
|
+
const event = JSON.parse(line);
|
|
985
|
+
if (event.type === "result" && typeof event.result === "string") finalReport = event.result;
|
|
986
|
+
if (event.type === "item.completed" && event.item?.type === "agent_message" && typeof event.item.text === "string") finalReport = event.item.text;
|
|
987
|
+
const text = event.type === "assistant" ? event.message?.content?.filter((p: any) => p.type === "text").map((p: any) => p.text).join("\n") : undefined;
|
|
988
|
+
if (text) finalReport = text;
|
|
989
|
+
} catch { /* unsupported event */ }
|
|
990
|
+
}
|
|
991
|
+
return { finalReport: finalReport?.slice(0, 131072) ?? null, transcript: raw.slice(0, 131072), truncated: raw.length > 131072 || (finalReport?.length ?? 0) > 131072 };
|
|
992
|
+
}
|