@hue-run/sdk 0.1.5 → 0.2.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/ENVIRONMENTS.md +182 -0
- package/EVALUATIONS.md +12 -0
- package/README.md +192 -18
- package/dist/ai-sdk.d.ts +9 -1
- package/dist/ai-sdk.js +34 -8
- package/dist/client.d.ts +121 -6
- package/dist/client.js +329 -56
- package/dist/config.d.ts +11 -2
- package/dist/config.js +36 -7
- package/dist/environment/client.d.ts +73 -0
- package/dist/environment/client.js +209 -0
- package/dist/environment/tools.d.ts +30 -0
- package/dist/environment/tools.js +24 -0
- package/dist/environment/types.d.ts +429 -0
- package/dist/environment/types.js +1 -0
- package/dist/environment.d.ts +5 -0
- package/dist/environment.js +2 -0
- package/dist/evals/attempt.d.ts +454 -0
- package/dist/evals/attempt.js +687 -0
- package/dist/evals/client.d.ts +99 -5
- package/dist/evals/client.js +136 -7
- package/dist/evals/environment-evidence.d.ts +6 -0
- package/dist/evals/environment-evidence.js +123 -0
- package/dist/evals/environment-json.d.ts +3 -0
- package/dist/evals/environment-json.js +76 -0
- package/dist/evals/json.d.ts +9 -1
- package/dist/evals/json.js +14 -6
- package/dist/evals/runner.d.ts +61 -2
- package/dist/evals/runner.js +71 -9
- package/dist/evals/scorer-publication.d.ts +2 -0
- package/dist/evals/scorer-publication.js +84 -0
- package/dist/evals/scorers.d.ts +11 -0
- package/dist/evals/scorers.js +56 -5
- package/dist/evals/simulation.d.ts +184 -0
- package/dist/evals/simulation.js +603 -0
- package/dist/evals/types.d.ts +304 -0
- package/dist/evals.d.ts +5 -1
- package/dist/evals.js +3 -1
- package/dist/experimental-telemetry.d.ts +8 -0
- package/dist/experimental-telemetry.js +13 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/managed.d.ts +51 -1
- package/dist/managed.js +11 -1
- package/dist/privacy.d.ts +2 -0
- package/dist/privacy.js +16 -1
- package/dist/receipt.d.ts +12 -1
- package/dist/receipt.js +10 -1
- package/dist/safety.d.ts +1 -2
- package/dist/snapshot.js +4 -0
- package/dist/transport.d.ts +41 -9
- package/dist/transport.js +80 -22
- package/dist/types.d.ts +144 -8
- package/dist/version.d.ts +2 -0
- package/dist/version.js +3 -0
- package/package.json +51 -15
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { HueEnvironmentError } from "../environment/client.js";
|
|
4
|
+
import { bindEnvironmentTools } from "../environment/tools.js";
|
|
5
|
+
import { HueApiError } from "./client.js";
|
|
6
|
+
import { CheckpointStore } from "./checkpoint.js";
|
|
7
|
+
import { MAX_ENVIRONMENT_STEPS } from "./environment-evidence.js";
|
|
8
|
+
import { aggregateBounds, digest, json } from "./json.js";
|
|
9
|
+
import { normalizeScorerDefinitionForPublication } from "./scorer-publication.js";
|
|
10
|
+
import { actualAgentManifestV2, attemptBaselineV2, projectMcpConnectionV2, requestedAttemptProvidersV2, validateAttemptConnectionBundleV2, } from "./attempt.js";
|
|
11
|
+
import { runExperiment, TargetCancelledError, TargetOutcomeUncertainError, } from "./runner.js";
|
|
12
|
+
const scorerDefinition = (entry) => "definition" in entry.scorer ? entry.scorer.definition : entry.scorer;
|
|
13
|
+
function requestedAttempt(options) {
|
|
14
|
+
const requested = options.requestedProviders !== undefined;
|
|
15
|
+
const selected = options.mcpSurface !== undefined;
|
|
16
|
+
if (!requested && !selected) {
|
|
17
|
+
if (options.actualAgentManifest !== undefined)
|
|
18
|
+
throw new TypeError("actualAgentManifest requires requestedProviders and mcpSurface");
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
if (!requested || !selected)
|
|
22
|
+
throw new TypeError("requestedProviders and mcpSurface must be supplied together");
|
|
23
|
+
const requestedProviders = requestedAttemptProvidersV2.parse(options.requestedProviders);
|
|
24
|
+
const mcpSurface = options.mcpSurface;
|
|
25
|
+
const provider = requestedProviders.find((candidate) => candidate.providerInstanceKey === mcpSurface.providerInstanceKey);
|
|
26
|
+
if (!provider?.surfaceKeys.includes(mcpSurface.surfaceKey))
|
|
27
|
+
throw new TypeError("mcpSurface must identify an exactly requested MCP surface");
|
|
28
|
+
const actualAgentManifest = typeof options.actualAgentManifest === "function"
|
|
29
|
+
? options.actualAgentManifest
|
|
30
|
+
: actualAgentManifestV2.parse(options.actualAgentManifest);
|
|
31
|
+
return {
|
|
32
|
+
actualAgentManifest,
|
|
33
|
+
requestedProviders,
|
|
34
|
+
mcpSurface: { ...mcpSurface },
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function expectedManifestDigest(config) {
|
|
38
|
+
if (!config || typeof config !== "object" || Array.isArray(config))
|
|
39
|
+
throw new TypeError("Provider-profile simulations require an immutable V2 attempt baseline");
|
|
40
|
+
const source = config;
|
|
41
|
+
const baseline = attemptBaselineV2.safeParse(source.attemptBaselineV2);
|
|
42
|
+
if (!baseline.success) {
|
|
43
|
+
if (source.attemptBaselineV2 === undefined && source.attemptBaselineV1 !== undefined)
|
|
44
|
+
throw new TypeError("Legacy V1 attempts require a fresh experiment with a V2 baseline");
|
|
45
|
+
if (source.attemptBaselineV2 !== undefined)
|
|
46
|
+
throw new TypeError("The immutable V2 attempt baseline is invalid");
|
|
47
|
+
throw new TypeError("Provider-profile simulations require an immutable V2 attempt baseline");
|
|
48
|
+
}
|
|
49
|
+
return baseline.data.expectedAgentManifestDigest;
|
|
50
|
+
}
|
|
51
|
+
function normalizedEnvironmentDefinition(definition) {
|
|
52
|
+
return json({
|
|
53
|
+
...definition,
|
|
54
|
+
determinism: {
|
|
55
|
+
clock: {
|
|
56
|
+
startNs: definition.determinism?.clock?.startNs ?? "0",
|
|
57
|
+
stepAdvanceNs: definition.determinism?.clock?.stepAdvanceNs ?? "1000000",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
actions: definition.actions.map((action) => ({
|
|
61
|
+
...action,
|
|
62
|
+
params: (action.params ?? []).map((parameter) => ({
|
|
63
|
+
...parameter,
|
|
64
|
+
required: parameter.required ?? true,
|
|
65
|
+
})),
|
|
66
|
+
semantics: {
|
|
67
|
+
...action.semantics,
|
|
68
|
+
config: {
|
|
69
|
+
...action.semantics.config,
|
|
70
|
+
guards: action.semantics.config.guards ?? [],
|
|
71
|
+
notFoundError: action.semantics.config.notFoundError ?? "not_found",
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
observation: action.observation ?? { projection: "identity" },
|
|
75
|
+
})),
|
|
76
|
+
provenance: definition.provenance ?? { kind: "handwritten" },
|
|
77
|
+
metadata: definition.metadata ?? {},
|
|
78
|
+
}, aggregateBounds(240_000));
|
|
79
|
+
}
|
|
80
|
+
function scenarioIdentity(scenario) {
|
|
81
|
+
if (scenario.kind === "experiment")
|
|
82
|
+
return scenario;
|
|
83
|
+
return json({
|
|
84
|
+
kind: scenario.kind,
|
|
85
|
+
name: scenario.name,
|
|
86
|
+
slug: scenario.slug,
|
|
87
|
+
description: scenario.description ?? "",
|
|
88
|
+
environment: {
|
|
89
|
+
...scenario.environment,
|
|
90
|
+
definition: normalizedEnvironmentDefinition(scenario.environment.definition),
|
|
91
|
+
},
|
|
92
|
+
cases: scenario.cases,
|
|
93
|
+
scorers: scenario.scorers.map(({ scorer, ...identity }) => ({
|
|
94
|
+
...identity,
|
|
95
|
+
definition: normalizeScorerDefinitionForPublication("definition" in scorer ? scorer.definition : scorer),
|
|
96
|
+
})),
|
|
97
|
+
config: scenario.config ?? {},
|
|
98
|
+
}, aggregateBounds(8 * 1024 * 1024));
|
|
99
|
+
}
|
|
100
|
+
async function findBySlug(page, slug) {
|
|
101
|
+
let after;
|
|
102
|
+
for (;;) {
|
|
103
|
+
const result = await page(after);
|
|
104
|
+
const found = result.items.find((item) => item.slug === slug);
|
|
105
|
+
if (found)
|
|
106
|
+
return found;
|
|
107
|
+
if (!result.nextCursor)
|
|
108
|
+
return undefined;
|
|
109
|
+
after = result.nextCursor;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function canReconcileWrite(error) {
|
|
113
|
+
if (!(error instanceof HueApiError || error instanceof HueEnvironmentError))
|
|
114
|
+
return false;
|
|
115
|
+
return error.status === undefined || error.status === 409 || error.status >= 500;
|
|
116
|
+
}
|
|
117
|
+
/** Re-read only writes whose acknowledgement can be ambiguous or whose 409 can be a
|
|
118
|
+
* concurrent matching publication. Local encoding and deterministic 4xx failures are
|
|
119
|
+
* caller errors and retain their original type/status.
|
|
120
|
+
*/
|
|
121
|
+
async function reconcileWrite(error, read, unavailableMessage) {
|
|
122
|
+
if (!canReconcileWrite(error))
|
|
123
|
+
throw error;
|
|
124
|
+
let recovered;
|
|
125
|
+
try {
|
|
126
|
+
recovered = await read();
|
|
127
|
+
}
|
|
128
|
+
catch (readError) {
|
|
129
|
+
throw new Error(unavailableMessage, {
|
|
130
|
+
cause: new AggregateError([error, readError], "Write and reconciliation both failed"),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
if (recovered !== undefined)
|
|
134
|
+
return recovered;
|
|
135
|
+
// A received conflict is deterministic when no matching concurrent write exists.
|
|
136
|
+
if (error.status === 409)
|
|
137
|
+
throw error;
|
|
138
|
+
throw new Error(unavailableMessage, { cause: error });
|
|
139
|
+
}
|
|
140
|
+
async function resolveEnvironment(client, source) {
|
|
141
|
+
let identity = await findBySlug((after) => client.listEnvironments({ after, limit: 100 }), source.slug);
|
|
142
|
+
if (!identity) {
|
|
143
|
+
try {
|
|
144
|
+
identity = await client.createEnvironment({
|
|
145
|
+
name: source.name,
|
|
146
|
+
slug: source.slug,
|
|
147
|
+
...(source.description === undefined ? {} : { description: source.description }),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
identity = await reconcileWrite(error, () => findBySlug((after) => client.listEnvironments({ after, limit: 100 }), source.slug), "Environment creation acknowledgement is unavailable");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (identity.archivedAt)
|
|
155
|
+
throw new Error("Repository scenario environment is archived");
|
|
156
|
+
const definitionDigest = digest(normalizedEnvironmentDefinition(source.definition));
|
|
157
|
+
const current = await client.getEnvironment(identity.id);
|
|
158
|
+
const existing = current.versions.find((version) => version.contentDigest === definitionDigest);
|
|
159
|
+
if (existing)
|
|
160
|
+
return existing.id;
|
|
161
|
+
try {
|
|
162
|
+
return (await client.publishVersion(identity.id, source.definition)).id;
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
return (await reconcileWrite(error, async () => (await client.getEnvironment(identity.id)).versions.find((version) => version.contentDigest === definitionDigest), "Environment publication acknowledgement is unavailable")).id;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async function resolveScorers(client, sources) {
|
|
169
|
+
if (!sources.length)
|
|
170
|
+
throw new TypeError("Repository scenarios require at least one scorer");
|
|
171
|
+
const versionIds = [];
|
|
172
|
+
const bindings = [];
|
|
173
|
+
for (const source of sources) {
|
|
174
|
+
const supplied = scorerDefinition(source);
|
|
175
|
+
const definition = normalizeScorerDefinitionForPublication(supplied);
|
|
176
|
+
let identity = await findBySlug((after) => client.listScorers({ after, limit: 100, includeArchived: true }), source.slug);
|
|
177
|
+
if (!identity) {
|
|
178
|
+
try {
|
|
179
|
+
identity = await client.createScorer({
|
|
180
|
+
name: source.name,
|
|
181
|
+
slug: source.slug,
|
|
182
|
+
...(source.description === undefined ? {} : { description: source.description }),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
identity = await reconcileWrite(error, () => findBySlug((after) => client.listScorers({ after, limit: 100, includeArchived: true }), source.slug), "Scorer creation acknowledgement is unavailable");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (identity.archivedAt)
|
|
190
|
+
throw new Error("Repository scenario scorer is archived");
|
|
191
|
+
const full = await client.getScorer(identity.id);
|
|
192
|
+
const definitionDigest = digest(definition);
|
|
193
|
+
let version = full.versions?.find((item) => item.contentDigest === definitionDigest);
|
|
194
|
+
if (!version) {
|
|
195
|
+
try {
|
|
196
|
+
version = await client.publishScorerVersion(identity.id, definition);
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
version = await reconcileWrite(error, async () => (await client.getScorer(identity.id)).versions?.find((item) => item.contentDigest === definitionDigest), "Scorer publication acknowledgement is unavailable");
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
versionIds.push(version.id);
|
|
203
|
+
if ("definition" in source.scorer)
|
|
204
|
+
bindings.push(source.scorer);
|
|
205
|
+
}
|
|
206
|
+
return { versionIds, bindings };
|
|
207
|
+
}
|
|
208
|
+
const normalizedCase = (value, environmentVersionId) => ({
|
|
209
|
+
externalKey: value.externalKey,
|
|
210
|
+
inputs: json(value.inputs),
|
|
211
|
+
...(Object.hasOwn(value, "expected") ? { expected: json(value.expected) } : {}),
|
|
212
|
+
metadata: json(value.metadata ?? {}),
|
|
213
|
+
environmentVersionId,
|
|
214
|
+
});
|
|
215
|
+
const caseDigestValue = (value) => ({
|
|
216
|
+
externalKey: value.externalKey,
|
|
217
|
+
inputs: value.inputs,
|
|
218
|
+
expected: Object.hasOwn(value, "expected") ? value.expected : null,
|
|
219
|
+
hasExpected: Object.hasOwn(value, "expected"),
|
|
220
|
+
metadata: value.metadata ?? {},
|
|
221
|
+
sourceTraceId: value.sourceTraceId ?? null,
|
|
222
|
+
sourceTraceRevision: value.sourceTraceRevision ?? null,
|
|
223
|
+
...(value.artifactManifestId ? { artifactManifestId: value.artifactManifestId } : {}),
|
|
224
|
+
...(value.environmentVersionId ? { environmentVersionId: value.environmentVersionId } : {}),
|
|
225
|
+
});
|
|
226
|
+
async function resolveDataset(client, scenario, environmentVersionId) {
|
|
227
|
+
if (!scenario.cases.length)
|
|
228
|
+
throw new TypeError("Repository scenarios require at least one case");
|
|
229
|
+
const cases = scenario.cases.map((item) => normalizedCase(item, environmentVersionId));
|
|
230
|
+
const keys = new Set(cases.map((item) => item.externalKey));
|
|
231
|
+
if (keys.size !== cases.length)
|
|
232
|
+
throw new TypeError("Repository scenario case keys must be unique");
|
|
233
|
+
const ordered = [...cases].sort((a, b) => Buffer.compare(Buffer.from(a.externalKey), Buffer.from(b.externalKey)));
|
|
234
|
+
const wantedDigest = digest(ordered.map(caseDigestValue));
|
|
235
|
+
let identity = await findBySlug((after) => client.listDatasets({ after, limit: 100, includeArchived: true }), scenario.slug);
|
|
236
|
+
if (!identity) {
|
|
237
|
+
try {
|
|
238
|
+
identity = await client.createDataset({
|
|
239
|
+
name: scenario.name,
|
|
240
|
+
slug: scenario.slug,
|
|
241
|
+
...(scenario.description === undefined ? {} : { description: scenario.description }),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
identity = await reconcileWrite(error, () => findBySlug((after) => client.listDatasets({ after, limit: 100, includeArchived: true }), scenario.slug), "Dataset creation acknowledgement is unavailable");
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (identity.archivedAt)
|
|
249
|
+
throw new Error("Repository scenario dataset is archived");
|
|
250
|
+
let dataset = await client.getDataset(identity.id);
|
|
251
|
+
const frozen = dataset.versions.find((version) => version.contentDigest === wantedDigest);
|
|
252
|
+
if (frozen)
|
|
253
|
+
return frozen.id;
|
|
254
|
+
let draft = dataset.versions.find((version) => !version.frozenAt);
|
|
255
|
+
if (!draft) {
|
|
256
|
+
try {
|
|
257
|
+
draft = await client.createDatasetVersion(identity.id);
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
draft = await reconcileWrite(error, async () => {
|
|
261
|
+
dataset = await client.getDataset(identity.id);
|
|
262
|
+
return dataset.versions.find((version) => !version.frozenAt);
|
|
263
|
+
}, "Dataset draft creation acknowledgement is unavailable");
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const current = await allCases(client, draft.id);
|
|
267
|
+
const desired = new Map(cases.map((item) => [item.externalKey, item]));
|
|
268
|
+
for (const item of current) {
|
|
269
|
+
const expected = desired.get(item.externalKey);
|
|
270
|
+
if (!expected || digest(caseDigestValue(item)) !== digest(caseDigestValue(expected)))
|
|
271
|
+
throw new Error("Repository scenario conflicts with an existing mutable dataset draft");
|
|
272
|
+
desired.delete(item.externalKey);
|
|
273
|
+
}
|
|
274
|
+
for (const item of cases) {
|
|
275
|
+
if (!desired.has(item.externalKey))
|
|
276
|
+
continue;
|
|
277
|
+
const draftId = draft.id;
|
|
278
|
+
const expectedRevision = draft.revision;
|
|
279
|
+
try {
|
|
280
|
+
const added = await client.addCase(draftId, {
|
|
281
|
+
...item,
|
|
282
|
+
expectedRevision,
|
|
283
|
+
});
|
|
284
|
+
draft = added.version;
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
await reconcileWrite(error, async () => {
|
|
288
|
+
const recovered = (await allCases(client, draftId)).find((value) => value.externalKey === item.externalKey);
|
|
289
|
+
return recovered && digest(caseDigestValue(recovered)) === digest(caseDigestValue(item))
|
|
290
|
+
? recovered
|
|
291
|
+
: undefined;
|
|
292
|
+
}, "Dataset case write acknowledgement is unavailable");
|
|
293
|
+
draft = await client.getDatasetVersion(draftId);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
try {
|
|
297
|
+
return (await client.freezeDatasetVersion(draft.id, draft.revision)).id;
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
return (await reconcileWrite(error, async () => {
|
|
301
|
+
const recovered = await client.getDatasetVersion(draft.id);
|
|
302
|
+
return recovered.contentDigest === wantedDigest ? recovered : undefined;
|
|
303
|
+
}, "Dataset freeze acknowledgement is unavailable")).id;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
async function allCases(client, versionId) {
|
|
307
|
+
const items = [];
|
|
308
|
+
let after;
|
|
309
|
+
for (;;) {
|
|
310
|
+
const page = await client.listCases(versionId, { after, limit: 100 });
|
|
311
|
+
items.push(...page.items);
|
|
312
|
+
if (!page.nextCursor)
|
|
313
|
+
return items;
|
|
314
|
+
after = page.nextCursor;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
async function resolveExperiment(options, idempotencyKey) {
|
|
318
|
+
if (options.scenario.kind === "experiment") {
|
|
319
|
+
const source = await options.client.getExperiment(options.scenario.experimentId);
|
|
320
|
+
const created = await options.client.createExperiment({
|
|
321
|
+
idempotencyKey,
|
|
322
|
+
name: options.runName ?? source.name,
|
|
323
|
+
datasetVersionId: source.datasetVersionId,
|
|
324
|
+
scorerVersionIds: source.evaluation.scorerVersions.map((item) => item.id),
|
|
325
|
+
config: source.config,
|
|
326
|
+
});
|
|
327
|
+
return { experimentId: created.id, bindings: options.localScorers ?? [] };
|
|
328
|
+
}
|
|
329
|
+
const environmentVersionId = await resolveEnvironment(options.environmentClient, options.scenario.environment);
|
|
330
|
+
const datasetVersionId = await resolveDataset(options.client, options.scenario, environmentVersionId);
|
|
331
|
+
const scorers = await resolveScorers(options.client, options.scenario.scorers);
|
|
332
|
+
const created = await options.client.createExperiment({
|
|
333
|
+
idempotencyKey,
|
|
334
|
+
name: options.runName ?? options.scenario.name,
|
|
335
|
+
datasetVersionId,
|
|
336
|
+
scorerVersionIds: scorers.versionIds,
|
|
337
|
+
config: options.scenario.config ?? {},
|
|
338
|
+
});
|
|
339
|
+
return {
|
|
340
|
+
experimentId: created.id,
|
|
341
|
+
bindings: [...scorers.bindings, ...(options.localScorers ?? [])],
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
async function seal(client, runId, executionId, status) {
|
|
345
|
+
try {
|
|
346
|
+
await client.finishRun(runId, {
|
|
347
|
+
idempotencyKey: `execution:${executionId}:${status}`,
|
|
348
|
+
status,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
const recovered = await client.getRun(runId).catch(() => undefined);
|
|
353
|
+
if (recovered?.status !== status)
|
|
354
|
+
throw error;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/** Run an existing agent callback against one fresh hosted world per case. The helper
|
|
358
|
+
* owns immutable resolution, execution linkage, finalization, scoring and resumable uploads.
|
|
359
|
+
*/
|
|
360
|
+
export async function runSimulation(options) {
|
|
361
|
+
const requestedConfiguration = requestedAttempt(options);
|
|
362
|
+
if (options.maxSteps !== undefined &&
|
|
363
|
+
(!Number.isInteger(options.maxSteps) ||
|
|
364
|
+
options.maxSteps < 1 ||
|
|
365
|
+
options.maxSteps > MAX_ENVIRONMENT_STEPS))
|
|
366
|
+
throw new RangeError(`maxSteps must be 1–${MAX_ENVIRONMENT_STEPS}`);
|
|
367
|
+
if (options.ttlSeconds !== undefined &&
|
|
368
|
+
(!Number.isInteger(options.ttlSeconds) || options.ttlSeconds < 1 || options.ttlSeconds > 86_400))
|
|
369
|
+
throw new RangeError("ttlSeconds must be 1–86400");
|
|
370
|
+
if (options.environmentClient.baseUrl !== options.client.baseUrl)
|
|
371
|
+
throw new Error("Environments and evaluations must use the same Hue origin");
|
|
372
|
+
const project = await options.client.checkConnection();
|
|
373
|
+
const store = await CheckpointStore.acquire(options.checkpointDirectory, {
|
|
374
|
+
kind: "simulation",
|
|
375
|
+
projectId: project.id,
|
|
376
|
+
baseUrl: options.client.baseUrl,
|
|
377
|
+
});
|
|
378
|
+
try {
|
|
379
|
+
const scenarioDigest = digest(scenarioIdentity(options.scenario));
|
|
380
|
+
let attempt = await store.read("active-attempt");
|
|
381
|
+
if (attempt && attempt.stage !== "completed" && attempt.scenarioDigest !== scenarioDigest)
|
|
382
|
+
throw new Error("Recover the unfinished simulation before running a changed scenario");
|
|
383
|
+
if (!attempt || attempt.stage === "completed") {
|
|
384
|
+
attempt = {
|
|
385
|
+
scenarioDigest,
|
|
386
|
+
idempotencyKey: randomUUID(),
|
|
387
|
+
stage: "preparing",
|
|
388
|
+
};
|
|
389
|
+
await store.write("active-attempt", attempt);
|
|
390
|
+
}
|
|
391
|
+
let bindings = options.localScorers ?? [];
|
|
392
|
+
if (!attempt.experimentId) {
|
|
393
|
+
const resolved = await resolveExperiment(options, attempt.idempotencyKey);
|
|
394
|
+
attempt.experimentId = resolved.experimentId;
|
|
395
|
+
bindings = resolved.bindings;
|
|
396
|
+
attempt.stage = "running";
|
|
397
|
+
await store.write("active-attempt", attempt);
|
|
398
|
+
}
|
|
399
|
+
else if (options.scenario.kind === "repository") {
|
|
400
|
+
bindings = [
|
|
401
|
+
...options.scenario.scorers
|
|
402
|
+
.filter((item) => "definition" in item.scorer)
|
|
403
|
+
.map((item) => item.scorer),
|
|
404
|
+
...(options.localScorers ?? []),
|
|
405
|
+
];
|
|
406
|
+
}
|
|
407
|
+
const experimentId = attempt.experimentId;
|
|
408
|
+
const runUrl = new URL(`/experiments/${experimentId}`, options.client.baseUrl).toString();
|
|
409
|
+
await options.onProgress?.({ type: "run_created", experimentId, runUrl });
|
|
410
|
+
const requested = requestedConfiguration
|
|
411
|
+
? {
|
|
412
|
+
...requestedConfiguration,
|
|
413
|
+
expectedAgentManifestDigest: expectedManifestDigest((await options.client.getExperiment(experimentId)).config),
|
|
414
|
+
}
|
|
415
|
+
: undefined;
|
|
416
|
+
const report = await runExperiment({
|
|
417
|
+
client: options.client,
|
|
418
|
+
hue: options.hue,
|
|
419
|
+
experimentId,
|
|
420
|
+
checkpointDirectory: join(store.directory, `experiment-${experimentId}`),
|
|
421
|
+
persistResultContent: options.persistResultContent,
|
|
422
|
+
traceEvidence: options.traceEvidence,
|
|
423
|
+
environmentEvidence: "required",
|
|
424
|
+
scorers: bindings,
|
|
425
|
+
concurrency: options.concurrency,
|
|
426
|
+
schemaTimeoutMillis: options.schemaTimeoutMillis,
|
|
427
|
+
target: async (inputs, context) => {
|
|
428
|
+
const environmentVersionId = context.item.environmentVersionId;
|
|
429
|
+
if (!environmentVersionId)
|
|
430
|
+
throw new Error("The simulation case has no pinned environment version");
|
|
431
|
+
const run = await options.environmentClient.createRun({
|
|
432
|
+
idempotencyKey: `execution:${context.executionId}`,
|
|
433
|
+
environmentVersionId,
|
|
434
|
+
executionId: context.executionId,
|
|
435
|
+
maxSteps: options.maxSteps,
|
|
436
|
+
ttlSeconds: options.ttlSeconds,
|
|
437
|
+
});
|
|
438
|
+
const progress = (type) => options.onProgress?.({
|
|
439
|
+
type,
|
|
440
|
+
experimentId,
|
|
441
|
+
executionId: context.executionId,
|
|
442
|
+
caseId: context.item.id,
|
|
443
|
+
environmentRunId: run.id,
|
|
444
|
+
});
|
|
445
|
+
let finalized = false;
|
|
446
|
+
try {
|
|
447
|
+
await progress("world_created");
|
|
448
|
+
if (options.signal?.aborted)
|
|
449
|
+
throw new TargetCancelledError();
|
|
450
|
+
const tools = bindEnvironmentTools({
|
|
451
|
+
hue: options.hue,
|
|
452
|
+
client: options.environmentClient,
|
|
453
|
+
run,
|
|
454
|
+
parentContext: context.span.context,
|
|
455
|
+
});
|
|
456
|
+
let connectionBundle;
|
|
457
|
+
let mcp;
|
|
458
|
+
if (requested) {
|
|
459
|
+
const actualManifest = actualAgentManifestV2.parse(typeof requested.actualAgentManifest === "function"
|
|
460
|
+
? await requested.actualAgentManifest({
|
|
461
|
+
config: context.config,
|
|
462
|
+
item: structuredClone(context.item),
|
|
463
|
+
signal: options.signal,
|
|
464
|
+
})
|
|
465
|
+
: requested.actualAgentManifest);
|
|
466
|
+
let prepared;
|
|
467
|
+
try {
|
|
468
|
+
prepared = await options.client.prepareAttempt({
|
|
469
|
+
schemaVersion: 2,
|
|
470
|
+
idempotencyKey: randomUUID(),
|
|
471
|
+
executionId: context.executionId,
|
|
472
|
+
environmentRunId: run.id,
|
|
473
|
+
expectedAgentManifestDigest: requested.expectedAgentManifestDigest,
|
|
474
|
+
actualManifest,
|
|
475
|
+
requestedProviders: requested.requestedProviders,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
catch (error) {
|
|
479
|
+
// A transport failure or malformed credential-bearing response may
|
|
480
|
+
// follow a committed decision. Preserve the running checkpoint and
|
|
481
|
+
// never reacquire credentials or replay the target on resume.
|
|
482
|
+
throw new TargetOutcomeUncertainError(context.executionId, { cause: error });
|
|
483
|
+
}
|
|
484
|
+
await options.onProgress?.({
|
|
485
|
+
type: "attempt_prepared",
|
|
486
|
+
experimentId,
|
|
487
|
+
executionId: context.executionId,
|
|
488
|
+
caseId: context.item.id,
|
|
489
|
+
environmentRunId: run.id,
|
|
490
|
+
bindingId: prepared.status === "ready" ? prepared.bundle.bindingId : prepared.bindingId,
|
|
491
|
+
status: prepared.status,
|
|
492
|
+
findingCodes: prepared.preflightReport.findings.map((finding) => finding.code),
|
|
493
|
+
...(prepared.status === "ready"
|
|
494
|
+
? {
|
|
495
|
+
executionManifestDigest: prepared.bundle.parity.executionManifestDigest,
|
|
496
|
+
}
|
|
497
|
+
: {}),
|
|
498
|
+
});
|
|
499
|
+
if (prepared.status === "environment_incomplete") {
|
|
500
|
+
try {
|
|
501
|
+
await seal(options.environmentClient, run.id, context.executionId, "completed");
|
|
502
|
+
}
|
|
503
|
+
catch (error) {
|
|
504
|
+
throw new TargetOutcomeUncertainError(context.executionId, { cause: error });
|
|
505
|
+
}
|
|
506
|
+
finalized = true;
|
|
507
|
+
await Promise.resolve(progress("world_sealed")).catch(() => undefined);
|
|
508
|
+
return undefined;
|
|
509
|
+
}
|
|
510
|
+
connectionBundle = validateAttemptConnectionBundleV2(prepared.bundle, {
|
|
511
|
+
requireFresh: true,
|
|
512
|
+
});
|
|
513
|
+
const projected = projectMcpConnectionV2(connectionBundle, requested.mcpSurface.providerInstanceKey);
|
|
514
|
+
if (!projected)
|
|
515
|
+
throw new TypeError("The prepared attempt has no selected MCP surface");
|
|
516
|
+
mcp = projected;
|
|
517
|
+
}
|
|
518
|
+
else {
|
|
519
|
+
mcp = await options.client.createSimulationMcpCapability({
|
|
520
|
+
runId: run.id,
|
|
521
|
+
executionId: context.executionId,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
if (options.signal?.aborted)
|
|
525
|
+
throw new TargetCancelledError();
|
|
526
|
+
await progress("target_started");
|
|
527
|
+
const output = await options.target(inputs, {
|
|
528
|
+
config: context.config,
|
|
529
|
+
item: context.item,
|
|
530
|
+
executionId: context.executionId,
|
|
531
|
+
environmentRunId: run.id,
|
|
532
|
+
tools,
|
|
533
|
+
mcp,
|
|
534
|
+
...(connectionBundle ? { connectionBundle } : {}),
|
|
535
|
+
signal: options.signal,
|
|
536
|
+
});
|
|
537
|
+
try {
|
|
538
|
+
await seal(options.environmentClient, run.id, context.executionId, "completed");
|
|
539
|
+
}
|
|
540
|
+
catch (error) {
|
|
541
|
+
throw new TargetOutcomeUncertainError(context.executionId, { cause: error });
|
|
542
|
+
}
|
|
543
|
+
finalized = true;
|
|
544
|
+
await Promise.resolve(progress("world_sealed")).catch(() => undefined);
|
|
545
|
+
return output;
|
|
546
|
+
}
|
|
547
|
+
catch (error) {
|
|
548
|
+
if (error instanceof TargetOutcomeUncertainError || finalized)
|
|
549
|
+
throw error;
|
|
550
|
+
let environmentIncomplete;
|
|
551
|
+
try {
|
|
552
|
+
environmentIncomplete =
|
|
553
|
+
(await options.environmentClient.getRun(run.id)).validity ===
|
|
554
|
+
"environment_incomplete";
|
|
555
|
+
}
|
|
556
|
+
catch (inspectionError) {
|
|
557
|
+
// A target error can be the adapter surfacing a coverage gap. If the
|
|
558
|
+
// authoritative run cannot be read, do not guess that it was an agent
|
|
559
|
+
// failure or replay the target on resume.
|
|
560
|
+
throw new TargetOutcomeUncertainError(context.executionId, {
|
|
561
|
+
cause: new AggregateError([error, inspectionError]),
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
// A durable coverage gap invalidates parity independently of caller timing;
|
|
565
|
+
// do not let a racing local abort hide it as an ordinary cancellation.
|
|
566
|
+
if (environmentIncomplete) {
|
|
567
|
+
try {
|
|
568
|
+
await seal(options.environmentClient, run.id, context.executionId, "completed");
|
|
569
|
+
}
|
|
570
|
+
catch (finalizationError) {
|
|
571
|
+
throw new TargetOutcomeUncertainError(context.executionId, {
|
|
572
|
+
cause: new AggregateError([error, finalizationError]),
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
finalized = true;
|
|
576
|
+
await Promise.resolve(progress("world_sealed")).catch(() => undefined);
|
|
577
|
+
return undefined;
|
|
578
|
+
}
|
|
579
|
+
try {
|
|
580
|
+
await seal(options.environmentClient, run.id, context.executionId, "abandoned");
|
|
581
|
+
}
|
|
582
|
+
catch (finalizationError) {
|
|
583
|
+
throw new TargetOutcomeUncertainError(context.executionId, {
|
|
584
|
+
cause: new AggregateError([error, finalizationError]),
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
await Promise.resolve(progress("world_sealed")).catch(() => undefined);
|
|
588
|
+
if (options.signal?.aborted && !(error instanceof TargetCancelledError))
|
|
589
|
+
throw new TargetCancelledError();
|
|
590
|
+
throw error;
|
|
591
|
+
}
|
|
592
|
+
},
|
|
593
|
+
});
|
|
594
|
+
const complete = { ...report, experimentId, runUrl };
|
|
595
|
+
attempt.stage = "completed";
|
|
596
|
+
attempt.report = complete;
|
|
597
|
+
await store.write("active-attempt", attempt);
|
|
598
|
+
return complete;
|
|
599
|
+
}
|
|
600
|
+
finally {
|
|
601
|
+
await store.release();
|
|
602
|
+
}
|
|
603
|
+
}
|