@atlanai/sdk 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/dist/client.d.ts +20 -3
- package/dist/client.js +130 -9
- package/dist/errors.d.ts +10 -0
- package/dist/errors.js +34 -1
- package/dist/evals.d.ts +135 -8
- package/dist/evals.js +1004 -73
- package/dist/index.d.ts +3 -2
- package/dist/index.js +7 -1
- package/dist/manifest.json +84 -11
- package/dist/raw/agent/apis/AgentsApi.d.ts +29 -0
- package/dist/raw/agent/apis/AgentsApi.js +62 -1
- package/dist/raw/agent/apis/SessionsApi.d.ts +2 -2
- package/dist/raw/agent/apis/SessionsApi.js +2 -2
- package/dist/raw/agent/models/AgentAgentArtifactView.d.ts +48 -0
- package/dist/raw/agent/models/AgentAgentArtifactView.js +13 -0
- package/dist/raw/agent/models/AgentAgentEnvironmentArtifactView.d.ts +12 -0
- package/dist/raw/agent/models/AgentAgentEnvironmentArtifactView.js +2 -0
- package/dist/raw/agent/models/AgentAgentEnvironmentMeta.d.ts +12 -0
- package/dist/raw/agent/models/AgentAgentEnvironmentMeta.js +2 -0
- package/dist/raw/agent/models/AgentAgentMeta.d.ts +48 -0
- package/dist/raw/agent/models/AgentAgentMeta.js +13 -0
- package/dist/raw/agent/models/AgentAgentProviderArtifactView.d.ts +55 -2
- package/dist/raw/agent/models/AgentAgentProviderArtifactView.js +8 -0
- package/dist/raw/agent/models/AgentAgentProviderCreate.d.ts +24 -3
- package/dist/raw/agent/models/AgentAgentProviderCreate.js +4 -0
- package/dist/raw/agent/models/AgentAgentProviderMeta.d.ts +56 -13
- package/dist/raw/agent/models/AgentAgentProviderMeta.js +8 -0
- package/dist/raw/agent/models/AgentAgentProviderPatch.d.ts +13 -0
- package/dist/raw/agent/models/AgentAgentProviderPatch.js +4 -0
- package/dist/raw/agent/models/AgentAgentProviderType.d.ts +19 -9
- package/dist/raw/agent/models/AgentAgentProviderType.js +18 -8
- package/dist/raw/agent/models/AgentListAgentFrameworks401Response.d.ts +1 -0
- package/dist/raw/agent/models/AgentListAgentFrameworks401Response.js +1 -0
- package/dist/raw/agent/models/AgentPageSessionEventView.d.ts +3 -3
- package/dist/raw/agent/models/AgentPageSessionEventView.js +3 -3
- package/dist/raw/agent/models/AgentRemoteSessionEvent.d.ts +38 -0
- package/dist/raw/agent/models/AgentRemoteSessionEvent.js +58 -0
- package/dist/raw/agent/models/AgentSessionArtifactView.d.ts +25 -0
- package/dist/raw/agent/models/AgentSessionArtifactView.js +2 -0
- package/dist/raw/agent/models/AgentSessionEventView.d.ts +30 -0
- package/dist/raw/agent/models/AgentSessionEventView.js +57 -0
- package/dist/raw/agent/models/AgentSessionMeta.d.ts +25 -0
- package/dist/raw/agent/models/AgentSessionMeta.js +2 -0
- package/dist/raw/agent/models/AgentSessionStreamFrame.d.ts +8 -6
- package/dist/raw/agent/models/AgentSessionStreamFrame.js +7 -0
- package/dist/raw/agent/models/AgentSyncState.d.ts +27 -0
- package/dist/raw/agent/models/AgentSyncState.js +55 -0
- package/dist/raw/agent/models/TriggerBody.d.ts +52 -0
- package/dist/raw/agent/models/TriggerBody.js +58 -0
- package/dist/raw/agent/models/index.d.ts +4 -1
- package/dist/raw/agent/models/index.js +4 -1
- package/dist/raw/eval/models/EvalListDatasets401Response.d.ts +1 -0
- package/dist/raw/eval/models/EvalListDatasets401Response.js +1 -0
- package/dist/raw/file/models/FileList401Response.d.ts +1 -0
- package/dist/raw/file/models/FileList401Response.js +1 -0
- package/dist/raw/mcp/apis/ServersApi.d.ts +2 -2
- package/dist/raw/mcp/apis/ServersApi.js +2 -2
- package/dist/raw/mcp/models/McpConnectionState.d.ts +2 -1
- package/dist/raw/mcp/models/McpConnectionState.js +2 -1
- package/dist/raw/mcp/models/McpListServers401Response.d.ts +1 -0
- package/dist/raw/mcp/models/McpListServers401Response.js +1 -0
- package/dist/raw/mcp/models/McpServerTestResponse.d.ts +13 -2
- package/dist/raw/model/models/ModelListProviders401Response.d.ts +1 -0
- package/dist/raw/model/models/ModelListProviders401Response.js +1 -0
- package/dist/raw/registry/apis/WorkspacesApi.d.ts +28 -0
- package/dist/raw/registry/apis/WorkspacesApi.js +61 -1
- package/dist/raw/registry/models/CreateWorkspaceBody.d.ts +9 -0
- package/dist/raw/registry/models/CreateWorkspaceBody.js +2 -0
- package/dist/raw/registry/models/RegistryAggregate401Response.d.ts +1 -0
- package/dist/raw/registry/models/RegistryAggregate401Response.js +1 -0
- package/dist/raw/registry/models/RegistryWorkspaceView.d.ts +9 -0
- package/dist/raw/registry/models/RegistryWorkspaceView.js +4 -0
- package/dist/raw/registry/models/SearchRequest.d.ts +1 -1
- package/dist/raw/registry/models/SetVisibilityBody.d.ts +33 -0
- package/dist/raw/registry/models/SetVisibilityBody.js +50 -0
- package/dist/raw/registry/models/index.d.ts +1 -0
- package/dist/raw/registry/models/index.js +1 -0
- package/dist/raw/secret/apis/OauthApi.d.ts +143 -0
- package/dist/raw/secret/apis/OauthApi.js +335 -0
- package/dist/raw/secret/apis/index.d.ts +1 -0
- package/dist/raw/secret/apis/index.js +19 -0
- package/dist/raw/secret/index.d.ts +1 -0
- package/dist/raw/secret/index.js +1 -0
- package/dist/raw/secret/models/GatewayOauthCompleteRequest.d.ts +32 -0
- package/dist/raw/secret/models/GatewayOauthCompleteRequest.js +50 -0
- package/dist/raw/secret/models/GatewayOauthCompletion.d.ts +46 -0
- package/dist/raw/secret/models/GatewayOauthCompletion.js +55 -0
- package/dist/raw/secret/models/GatewayOauthConnection.d.ts +83 -0
- package/dist/raw/secret/models/GatewayOauthConnection.js +83 -0
- package/dist/raw/secret/models/GatewayOauthOwner.d.ts +31 -0
- package/dist/raw/secret/models/GatewayOauthOwner.js +59 -0
- package/dist/raw/secret/models/GatewayOauthProviderView.d.ts +69 -0
- package/dist/raw/secret/models/GatewayOauthProviderView.js +69 -0
- package/dist/raw/secret/models/GatewayOauthStartRequest.d.ts +52 -0
- package/dist/raw/secret/models/GatewayOauthStartRequest.js +59 -0
- package/dist/raw/secret/models/GatewayOauthStarted.d.ts +39 -0
- package/dist/raw/secret/models/GatewayOauthStarted.js +54 -0
- package/dist/raw/secret/models/SecretCompleteOauth400Response.d.ts +70 -0
- package/dist/raw/secret/models/SecretCompleteOauth400Response.js +80 -0
- package/dist/raw/secret/models/SecretCompleteOauth413Response.d.ts +64 -0
- package/dist/raw/secret/models/SecretCompleteOauth413Response.js +74 -0
- package/dist/raw/secret/models/SecretCompleteOauth415Response.d.ts +64 -0
- package/dist/raw/secret/models/SecretCompleteOauth415Response.js +74 -0
- package/dist/raw/secret/models/SecretCompleteOauth422Response.d.ts +63 -0
- package/dist/raw/secret/models/SecretCompleteOauth422Response.js +75 -0
- package/dist/raw/secret/models/SecretDisconnectOauth400Response.d.ts +67 -0
- package/dist/raw/secret/models/SecretDisconnectOauth400Response.js +77 -0
- package/dist/raw/secret/models/SecretOauthCallback400Response.d.ts +68 -0
- package/dist/raw/secret/models/SecretOauthCallback400Response.js +78 -0
- package/dist/raw/secret/models/SecretOauthCallback401Response.d.ts +80 -0
- package/dist/raw/secret/models/SecretOauthCallback401Response.js +90 -0
- package/dist/raw/secret/models/SecretOauthCallback403Response.d.ts +66 -0
- package/dist/raw/secret/models/SecretOauthCallback403Response.js +76 -0
- package/dist/raw/secret/models/SecretOauthCallback404Response.d.ts +65 -0
- package/dist/raw/secret/models/SecretOauthCallback404Response.js +75 -0
- package/dist/raw/secret/models/SecretOauthCallback500Response.d.ts +64 -0
- package/dist/raw/secret/models/SecretOauthCallback500Response.js +74 -0
- package/dist/raw/secret/models/SecretOauthCallback503Response.d.ts +66 -0
- package/dist/raw/secret/models/SecretOauthCallback503Response.js +76 -0
- package/dist/raw/secret/models/SecretOauthCallback504Response.d.ts +63 -0
- package/dist/raw/secret/models/SecretOauthCallback504Response.js +75 -0
- package/dist/raw/secret/models/index.d.ts +19 -0
- package/dist/raw/secret/models/index.js +19 -0
- package/dist/raw/skill/models/SkillInsightSchemas401Response.d.ts +1 -0
- package/dist/raw/skill/models/SkillInsightSchemas401Response.js +1 -0
- package/dist/resource-types.d.ts +670 -0
- package/dist/resource-types.js +2 -0
- package/dist/resources.d.ts +10 -0
- package/dist/resources.js +54 -4
- package/dist/resources.json +86 -0
- package/dist/tracing/client.d.ts +8 -1
- package/dist/tracing/client.js +43 -16
- package/dist/tracing/config.d.ts +3 -1
- package/dist/tracing/config.js +7 -2
- package/dist/tracing/exporter.d.ts +19 -1
- package/dist/tracing/exporter.js +56 -14
- package/dist/tracing/propagation.d.ts +2 -0
- package/dist/tracing/propagation.js +16 -0
- package/dist/tracing/semconv.d.ts +4 -0
- package/dist/tracing/semconv.js +14 -2
- package/dist/tracing/spans.d.ts +2 -0
- package/dist/tracing/spans.js +12 -1
- package/dist/tracing/version.d.ts +1 -1
- package/dist/tracing/version.js +1 -1
- package/package.json +1 -1
- package/dist/raw/agent/models/Box.d.ts +0 -342
- package/dist/raw/agent/models/Box.js +0 -167
package/dist/evals.js
CHANGED
|
@@ -4,10 +4,17 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
4
4
|
exports.EvalRun = exports.CONTEXT_MANIFEST_SCHEMA = void 0;
|
|
5
5
|
exports.createContextManifest = createContextManifest;
|
|
6
6
|
exports.resolveDataset = resolveDataset;
|
|
7
|
+
exports.recordName = recordName;
|
|
8
|
+
exports.pushRecords = pushRecords;
|
|
9
|
+
exports.pushDataset = pushDataset;
|
|
7
10
|
exports.startExperiment = startExperiment;
|
|
11
|
+
exports.experimentTraces = experimentTraces;
|
|
12
|
+
exports.recordExperimentSession = recordExperimentSession;
|
|
8
13
|
exports.Eval = Eval;
|
|
14
|
+
exports.verifyExperiment = verifyExperiment;
|
|
9
15
|
const client_1 = require("./client");
|
|
10
16
|
const errors_1 = require("./errors");
|
|
17
|
+
const AgentSubjectKind_1 = require("./raw/agent/models/AgentSubjectKind");
|
|
11
18
|
exports.CONTEXT_MANIFEST_SCHEMA = "atlan.eval.context-manifest/v1";
|
|
12
19
|
const SHA256 = /^sha256:[0-9a-f]{64}$/;
|
|
13
20
|
const TRACE_ID = /^[0-9a-f]{32}$/;
|
|
@@ -62,6 +69,9 @@ function manifestItem(item) {
|
|
|
62
69
|
if (item.version.toLowerCase() === "latest") {
|
|
63
70
|
throw new Error("context item version must be pinned; 'latest' is not a version");
|
|
64
71
|
}
|
|
72
|
+
if (item.artifactKind !== undefined && !CONTEXT_KIND.test(item.artifactKind)) {
|
|
73
|
+
throw new Error("context item artifactKind must be a lowercase identifier");
|
|
74
|
+
}
|
|
65
75
|
if (item.artifactId !== undefined) {
|
|
66
76
|
validText(item.artifactId, "context item artifactId", 200);
|
|
67
77
|
}
|
|
@@ -69,11 +79,18 @@ function manifestItem(item) {
|
|
|
69
79
|
(!Number.isInteger(item.versionOrdinal) || item.versionOrdinal < 1)) {
|
|
70
80
|
throw new Error("context item versionOrdinal must be an integer of at least 1");
|
|
71
81
|
}
|
|
82
|
+
if ((item.artifactId === undefined) !== (item.versionOrdinal === undefined)) {
|
|
83
|
+
throw new Error("context item Registry pins require artifactId and versionOrdinal");
|
|
84
|
+
}
|
|
85
|
+
if (item.artifactKind !== undefined && item.artifactId === undefined) {
|
|
86
|
+
throw new Error("context item artifactKind requires a Registry artifact pin");
|
|
87
|
+
}
|
|
72
88
|
return {
|
|
73
89
|
kind: item.kind,
|
|
74
90
|
name: item.name,
|
|
75
91
|
digest: item.digest,
|
|
76
92
|
version: item.version,
|
|
93
|
+
...(item.artifactKind === undefined ? {} : { artifact_kind: item.artifactKind }),
|
|
77
94
|
...(item.artifactId === undefined ? {} : { artifact_id: item.artifactId }),
|
|
78
95
|
...(item.versionOrdinal === undefined ? {} : { version_ordinal: item.versionOrdinal }),
|
|
79
96
|
};
|
|
@@ -97,6 +114,8 @@ async function createContextManifest(input) {
|
|
|
97
114
|
const digest = "sha256:" + [...new Uint8Array(hash)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
98
115
|
return Object.freeze({ ...body, items: Object.freeze(items), digest });
|
|
99
116
|
}
|
|
117
|
+
class DatasetNotFoundError extends Error {
|
|
118
|
+
}
|
|
100
119
|
/** Resolve one dataset by artifact ID or exact name, never fuzzy matching. */
|
|
101
120
|
async function resolveDataset(client, idOrExactName, workspaceId) {
|
|
102
121
|
if (!idOrExactName || idOrExactName.trim() !== idOrExactName) {
|
|
@@ -131,17 +150,263 @@ async function resolveDataset(client, idOrExactName, workspaceId) {
|
|
|
131
150
|
}
|
|
132
151
|
if (matches.length === 0) {
|
|
133
152
|
const scope = workspace === undefined ? "" : ` in workspace '${workspace}'`;
|
|
134
|
-
throw new
|
|
153
|
+
throw new DatasetNotFoundError(`no dataset named '${idOrExactName}'${scope}`);
|
|
135
154
|
}
|
|
136
155
|
if (matches.length > 1) {
|
|
137
156
|
throw new Error(`multiple datasets are named '${idOrExactName}'; pass the dataset ID`);
|
|
138
157
|
}
|
|
139
158
|
return matches[0];
|
|
140
159
|
}
|
|
160
|
+
// --- Pushing a suite -------------------------------------------------------
|
|
161
|
+
//
|
|
162
|
+
// A record name is unique per `(workspace, kind)`, not per dataset, and
|
|
163
|
+
// archiving never frees it, so a developer's name is spendable once per
|
|
164
|
+
// workspace. `name` is therefore a derived per-dataset handle and
|
|
165
|
+
// `displayName` carries the developer's key. Nothing reads a record by name.
|
|
166
|
+
const RECORD_NAME_DIGEST = 12;
|
|
167
|
+
const BULK_RECORD_LIMIT = 100;
|
|
168
|
+
// `name` is excluded: the gateway accepts it in a patch and ignores it
|
|
169
|
+
// (measured: the old name came back with the ordinal bumped).
|
|
170
|
+
const PUSHED_RECORD_FIELDS = [
|
|
171
|
+
"input",
|
|
172
|
+
"expected",
|
|
173
|
+
"label",
|
|
174
|
+
"categories",
|
|
175
|
+
"description",
|
|
176
|
+
"displayName",
|
|
177
|
+
"extra",
|
|
178
|
+
];
|
|
179
|
+
/**
|
|
180
|
+
* The Registry handle for the case called `key` inside `datasetId`.
|
|
181
|
+
*
|
|
182
|
+
* Deterministic, so pushing the same suite twice lands on the same row, and
|
|
183
|
+
* dataset-scoped, so the same suite can also be pushed into a new dataset.
|
|
184
|
+
*/
|
|
185
|
+
async function recordName(datasetId, key) {
|
|
186
|
+
nonEmpty(datasetId, "datasetId must be a non-empty string");
|
|
187
|
+
nonEmpty(key, "a pushed record needs a non-empty name");
|
|
188
|
+
if (!globalThis.crypto?.subtle) {
|
|
189
|
+
throw new Error("Web Crypto is required to derive a dataset record name");
|
|
190
|
+
}
|
|
191
|
+
const hash = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(`${datasetId}\u0000${key}`));
|
|
192
|
+
const digest = [...new Uint8Array(hash)]
|
|
193
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
194
|
+
.join("")
|
|
195
|
+
.slice(0, RECORD_NAME_DIGEST);
|
|
196
|
+
// The digest over the whole key is what keeps two cases apart; the prefix
|
|
197
|
+
// is only readability. The Python SDK derives the same handle.
|
|
198
|
+
return `${slug(key, "record")}-${digest}`;
|
|
199
|
+
}
|
|
200
|
+
function pushKey(record) {
|
|
201
|
+
const key = record.name;
|
|
202
|
+
if (typeof key !== "string" || key.trim().length === 0) {
|
|
203
|
+
throw new Error("every pushed record needs a non-empty 'name' to key it by");
|
|
204
|
+
}
|
|
205
|
+
return key;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* The subset of the caller's fields the live row does not already carry.
|
|
209
|
+
*
|
|
210
|
+
* The gateway mints a new `versionOrdinal` for every PATCH it accepts,
|
|
211
|
+
* including one that changes nothing, so an unconditional re-push would pile
|
|
212
|
+
* up empty versions on rows nobody edited.
|
|
213
|
+
*/
|
|
214
|
+
function recordChanges(existing, record) {
|
|
215
|
+
const changes = {};
|
|
216
|
+
for (const name of PUSHED_RECORD_FIELDS) {
|
|
217
|
+
if (!(name in record))
|
|
218
|
+
continue;
|
|
219
|
+
if (canonicalJson(field(existing, name) ?? null) !== canonicalJson(record[name] ?? null)) {
|
|
220
|
+
changes[name] = record[name];
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return changes;
|
|
224
|
+
}
|
|
225
|
+
async function listDatasetRecords(resources, datasetId) {
|
|
226
|
+
const items = [];
|
|
227
|
+
let offset = 0;
|
|
228
|
+
while (true) {
|
|
229
|
+
const page = await resources.datasets.records.list({ datasetId, limit: 500, offset });
|
|
230
|
+
const raw = field(page, "items");
|
|
231
|
+
const batch = Array.isArray(raw) ? raw : [];
|
|
232
|
+
items.push(...batch);
|
|
233
|
+
offset += batch.length;
|
|
234
|
+
const total = field(field(page, "page"), "total");
|
|
235
|
+
if (batch.length === 0 || (typeof total === "number" && offset >= total))
|
|
236
|
+
break;
|
|
237
|
+
if (offset > MAX_LIST_OFFSET)
|
|
238
|
+
throw new Error("dataset record listing exceeded 10000 rows");
|
|
239
|
+
}
|
|
240
|
+
return items;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Create or update each case, so the same suite can be pushed repeatedly.
|
|
244
|
+
*
|
|
245
|
+
* Each record's `name` is the developer's key for the case: it becomes the
|
|
246
|
+
* row's `displayName` and keys the Registry handle. A case already in the
|
|
247
|
+
* dataset is patched when its content moved and left alone when it did not.
|
|
248
|
+
*/
|
|
249
|
+
async function pushRecords(client, datasetId, records, workspaceId) {
|
|
250
|
+
const resources = asResources(client);
|
|
251
|
+
const workspace = workspaceId ?? resources.workspace;
|
|
252
|
+
if (typeof workspace !== "string" || workspace.length === 0) {
|
|
253
|
+
throw new Error("pushRecords needs a workspace: pass workspaceId or set one on the client");
|
|
254
|
+
}
|
|
255
|
+
const wanted = new Map();
|
|
256
|
+
for (const record of records) {
|
|
257
|
+
const key = pushKey(record);
|
|
258
|
+
if (record.input === undefined)
|
|
259
|
+
throw new Error(`pushed record '${key}' has no 'input'`);
|
|
260
|
+
const name = await recordName(datasetId, key);
|
|
261
|
+
if (wanted.has(name))
|
|
262
|
+
throw new Error(`the pushed suite has two records named '${key}'`);
|
|
263
|
+
wanted.set(name, { key, record });
|
|
264
|
+
}
|
|
265
|
+
if (wanted.size === 0)
|
|
266
|
+
return [];
|
|
267
|
+
const existing = new Map((await listDatasetRecords(resources, datasetId)).map((item) => [field(item, "name"), item]));
|
|
268
|
+
const pushed = new Map();
|
|
269
|
+
const toCreate = [];
|
|
270
|
+
for (const [name, { key, record }] of wanted) {
|
|
271
|
+
const live = existing.get(name);
|
|
272
|
+
if (live === undefined) {
|
|
273
|
+
const { name: _key, ...rest } = record;
|
|
274
|
+
toCreate.push({
|
|
275
|
+
key,
|
|
276
|
+
name,
|
|
277
|
+
body: {
|
|
278
|
+
...rest,
|
|
279
|
+
workspaceId: workspace,
|
|
280
|
+
name,
|
|
281
|
+
displayName: record.displayName ?? key,
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
const id = String(field(live, "id"));
|
|
287
|
+
const changes = recordChanges(live, record);
|
|
288
|
+
if (Object.keys(changes).length === 0) {
|
|
289
|
+
pushed.set(name, { key, name, id, action: "unchanged", record: live });
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const updated = await resources.datasets.records.update(datasetId, id, changes);
|
|
293
|
+
pushed.set(name, { key, name, id, action: "updated", record: updated });
|
|
294
|
+
}
|
|
295
|
+
for (let start = 0; start < toCreate.length; start += BULK_RECORD_LIMIT) {
|
|
296
|
+
const chunk = toCreate.slice(start, start + BULK_RECORD_LIMIT);
|
|
297
|
+
let response;
|
|
298
|
+
try {
|
|
299
|
+
response = await resources.datasets.records.createBulk(datasetId, {
|
|
300
|
+
items: chunk.map((item) => item.body),
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
// The transport refuses an all-rejected batch before the slots are
|
|
305
|
+
// visible, and can only say "the batch". Every case in this chunk was
|
|
306
|
+
// rejected, so they can be named without the slots.
|
|
307
|
+
if (!(error instanceof errors_1.AtlanAPIError) || error.code !== "bulk_all_rejected")
|
|
308
|
+
throw error;
|
|
309
|
+
throw rejectedRecords(error.status, chunk);
|
|
310
|
+
}
|
|
311
|
+
const raw = field(response, "items");
|
|
312
|
+
const slots = Array.isArray(raw) ? raw : [];
|
|
313
|
+
if (slots.length !== chunk.length) {
|
|
314
|
+
throw new Error(`bulk record create returned ${slots.length} slots for ${chunk.length} items`);
|
|
315
|
+
}
|
|
316
|
+
for (const [index, slot] of slots.entries()) {
|
|
317
|
+
const item = chunk[index];
|
|
318
|
+
const created = field(slot, "record");
|
|
319
|
+
const id = field(created, "id");
|
|
320
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
321
|
+
const status = field(slot, "statusCode") ?? field(slot, "status_code");
|
|
322
|
+
throw rejectedRecords(Number(status), [item]);
|
|
323
|
+
}
|
|
324
|
+
pushed.set(item.name, { key: item.key, name: item.name, id, action: "created", record: created });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return [...wanted.keys()].map((name) => pushed.get(name));
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Name the cases a bulk create refused, rather than blaming "the batch".
|
|
331
|
+
*
|
|
332
|
+
* The batch-level code is all the transport can know; the keys are only known
|
|
333
|
+
* here, and they are what a developer has to go and edit.
|
|
334
|
+
*/
|
|
335
|
+
function rejectedRecords(status, cases) {
|
|
336
|
+
const named = cases.map((item) => `'${item.key}' (registry handle '${item.name}')`).join(", ");
|
|
337
|
+
const spent = status === 409
|
|
338
|
+
? cases.length === 1
|
|
339
|
+
? "; that handle is already spent in this workspace, which a record in another dataset can do"
|
|
340
|
+
: "; those handles are already spent in this workspace, which records in another dataset can do"
|
|
341
|
+
: "";
|
|
342
|
+
return new Error(cases.length === 1
|
|
343
|
+
? `case ${named} was refused with status ${status}${spent}`
|
|
344
|
+
: `${cases.length} cases were refused with status ${status}: ${named}${spent}`);
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Push a suite under `name`, creating the dataset the first time only.
|
|
348
|
+
*
|
|
349
|
+
* Idempotent: run it again after correcting an expected value and the
|
|
350
|
+
* correction lands on the same rows, in the same dataset, without a 409.
|
|
351
|
+
*/
|
|
352
|
+
async function pushDataset(client, name, records, options = {}) {
|
|
353
|
+
const resources = asResources(client);
|
|
354
|
+
const workspace = options.workspaceId ?? resources.workspace;
|
|
355
|
+
if (typeof workspace !== "string" || workspace.length === 0) {
|
|
356
|
+
throw new Error("pushDataset needs a workspace: pass workspaceId or set one on the client");
|
|
357
|
+
}
|
|
358
|
+
let dataset;
|
|
359
|
+
let created = false;
|
|
360
|
+
try {
|
|
361
|
+
dataset = await resolveDataset(client, name, workspace);
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
if (!(error instanceof DatasetNotFoundError))
|
|
365
|
+
throw error;
|
|
366
|
+
const body = { workspaceId: workspace, name };
|
|
367
|
+
if (options.displayName !== undefined)
|
|
368
|
+
body.displayName = options.displayName;
|
|
369
|
+
if (options.description !== undefined)
|
|
370
|
+
body.description = options.description;
|
|
371
|
+
try {
|
|
372
|
+
dataset = await resources.datasets.create(body);
|
|
373
|
+
created = true;
|
|
374
|
+
}
|
|
375
|
+
catch (createError) {
|
|
376
|
+
if (!(createError instanceof errors_1.AtlanAPIError) || createError.status !== 409)
|
|
377
|
+
throw createError;
|
|
378
|
+
try {
|
|
379
|
+
dataset = await resolveDataset(client, name, workspace);
|
|
380
|
+
}
|
|
381
|
+
catch (resolveError) {
|
|
382
|
+
if (resolveError instanceof DatasetNotFoundError)
|
|
383
|
+
throw createError;
|
|
384
|
+
throw resolveError;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const datasetId = field(dataset, "id");
|
|
389
|
+
if (typeof datasetId !== "string" || datasetId.length === 0) {
|
|
390
|
+
throw new Error("dataset response did not contain an ID");
|
|
391
|
+
}
|
|
392
|
+
return {
|
|
393
|
+
dataset,
|
|
394
|
+
id: datasetId,
|
|
395
|
+
created,
|
|
396
|
+
records: await pushRecords(client, datasetId, records, workspace),
|
|
397
|
+
};
|
|
398
|
+
}
|
|
141
399
|
class EvalRun {
|
|
142
400
|
experiment;
|
|
143
401
|
dataset;
|
|
144
402
|
contextManifest;
|
|
403
|
+
/**
|
|
404
|
+
* The session id stamped on every span this run emits.
|
|
405
|
+
*
|
|
406
|
+
* It is the only join key the gateway promotes off an eval span that can
|
|
407
|
+
* reach the run's subject: `recordExperimentSession` registers this value
|
|
408
|
+
* against the experiment's subject.
|
|
409
|
+
*/
|
|
145
410
|
constructor(experiment, dataset, contextManifest) {
|
|
146
411
|
this.experiment = experiment;
|
|
147
412
|
this.dataset = dataset;
|
|
@@ -157,10 +422,20 @@ class EvalRun {
|
|
|
157
422
|
get experimentId() {
|
|
158
423
|
return this.id;
|
|
159
424
|
}
|
|
160
|
-
/**
|
|
425
|
+
/** Stable across retries because the experiment is the resumable unit. */
|
|
426
|
+
get sessionId() {
|
|
427
|
+
return this.id;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Options for `propagateAttributes` from `@atlanai/sdk/tracing`.
|
|
431
|
+
*
|
|
432
|
+
* Carries the experiment join and the subject join. A nested
|
|
433
|
+
* `propagateAttributes({ sessionId })` still wins for the spans inside it.
|
|
434
|
+
*/
|
|
161
435
|
get traceOptions() {
|
|
162
436
|
return {
|
|
163
437
|
experimentId: this.id,
|
|
438
|
+
sessionId: this.sessionId,
|
|
164
439
|
...(this.contextManifest === undefined
|
|
165
440
|
? {}
|
|
166
441
|
: { metadata: { context_manifest_digest: this.contextManifest.digest } }),
|
|
@@ -235,9 +510,127 @@ async function startExperiment(client, dataset, body, options = {}) {
|
|
|
235
510
|
}
|
|
236
511
|
return run;
|
|
237
512
|
}
|
|
513
|
+
/**
|
|
514
|
+
* Every trace an experiment recorded, newest first.
|
|
515
|
+
*
|
|
516
|
+
* **The experiment is the scope eval traces are filed under.** A subject's own
|
|
517
|
+
* Traces tab (`GET /agent/v1/agents/{id}/traces`) resolves on the trace's
|
|
518
|
+
* *creator identity*, so it lists a run only when the run's spans were exported
|
|
519
|
+
* with that agent's own credential. An eval exported with your user key or a
|
|
520
|
+
* service-account key is filed under that identity instead, and the agent's
|
|
521
|
+
* Traces tab reads empty even though every trace exists. That is not the traces
|
|
522
|
+
* going missing; it is a different scope. Read them here, and call
|
|
523
|
+
* `recordExperimentSession` so the run is also reachable from the subject's
|
|
524
|
+
* Sessions tab.
|
|
525
|
+
*
|
|
526
|
+
* To fill the agent's own Traces tab, export the spans as the agent: pass
|
|
527
|
+
* `logger: initLogger({ apiKey: <the agent's credential> })` while the
|
|
528
|
+
* management `client` keeps your own, since the agent's identity is not
|
|
529
|
+
* entitled to create experiments.
|
|
530
|
+
*/
|
|
531
|
+
async function experimentTraces(client, experimentId, options = {}) {
|
|
532
|
+
if (experimentId.trim().length === 0)
|
|
533
|
+
throw new Error("experimentId is required");
|
|
534
|
+
const limit = options.limit ?? 500;
|
|
535
|
+
if (limit <= 0)
|
|
536
|
+
throw new Error("limit must be greater than zero");
|
|
537
|
+
const resources = asResources(client);
|
|
538
|
+
const traces = [];
|
|
539
|
+
let cursor;
|
|
540
|
+
while (traces.length < limit) {
|
|
541
|
+
const page = await resources.experiments.traces.list({
|
|
542
|
+
experimentId,
|
|
543
|
+
limit: Math.min(500, limit - traces.length),
|
|
544
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
545
|
+
});
|
|
546
|
+
const items = field(page, "items");
|
|
547
|
+
const batch = Array.isArray(items) ? items : [];
|
|
548
|
+
traces.push(...batch);
|
|
549
|
+
const next = field(field(page, "page"), "nextCursor");
|
|
550
|
+
cursor = typeof next === "string" && next.length > 0 ? next : undefined;
|
|
551
|
+
if (batch.length === 0 || cursor === undefined)
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
return traces.slice(0, limit);
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Bind this run's traces to the experiment's subject, and return the record.
|
|
558
|
+
*
|
|
559
|
+
* The gateway promotes exactly one span attribute that can reach a subject: the
|
|
560
|
+
* session id. This registers a `session` artifact whose `subjectId` is the
|
|
561
|
+
* experiment's subject and whose `externalSessionId` is `run.sessionId`, the
|
|
562
|
+
* value `run.traceOptions` stamps on every span of the run. After it, the run
|
|
563
|
+
* appears on the subject's Sessions tab and `client.sessions.traces.list(id)`
|
|
564
|
+
* returns the run's traces.
|
|
565
|
+
*
|
|
566
|
+
* Returns `undefined` when the experiment names no subject, because then there
|
|
567
|
+
* is nothing to bind the run to. A prior accepted create is reconciled on
|
|
568
|
+
* `409`, so a resume after a lost response does not strand the experiment.
|
|
569
|
+
*/
|
|
570
|
+
// Which subject kinds a `session` artifact can name. Read off the generated
|
|
571
|
+
// enum, so a gateway that later adds one is picked up by a contract sync
|
|
572
|
+
// rather than needing this list edited.
|
|
573
|
+
const SESSION_SUBJECT_KINDS = new Set(Object.values(AgentSubjectKind_1.AgentSubjectKind));
|
|
574
|
+
async function recordExperimentSession(client, run, options = {}) {
|
|
575
|
+
const subjectKind = field(run.experiment, "subjectKind");
|
|
576
|
+
const subjectId = field(run.experiment, "subjectId");
|
|
577
|
+
if (typeof subjectKind !== "string" || typeof subjectId !== "string")
|
|
578
|
+
return undefined;
|
|
579
|
+
if (subjectKind.length === 0 || subjectId.length === 0)
|
|
580
|
+
return undefined;
|
|
581
|
+
// EvalSubjectKind has `skill`, AgentSubjectKind does not, so a skill eval
|
|
582
|
+
// has no session to bind to. Throwing would fail a run whose results are
|
|
583
|
+
// already persisted, with no remedy that could succeed.
|
|
584
|
+
if (!SESSION_SUBJECT_KINDS.has(subjectKind))
|
|
585
|
+
return undefined;
|
|
586
|
+
const resources = asResources(client);
|
|
587
|
+
const workspaceId = field(run.experiment, "workspaceId") ?? resources.workspace;
|
|
588
|
+
if (typeof workspaceId !== "string" || workspaceId.length === 0) {
|
|
589
|
+
throw new Error("experiment has no workspaceId and the client has no default");
|
|
590
|
+
}
|
|
591
|
+
const displayName = field(run.experiment, "displayName") ?? field(run.experiment, "name");
|
|
592
|
+
const body = {
|
|
593
|
+
workspaceId,
|
|
594
|
+
name: `${slug(run.id, "experiment")}-traces`,
|
|
595
|
+
subjectKind,
|
|
596
|
+
subjectId,
|
|
597
|
+
sessionStatus: options.sessionStatus ?? "completed",
|
|
598
|
+
externalSessionId: run.sessionId,
|
|
599
|
+
sourceType: "local",
|
|
600
|
+
};
|
|
601
|
+
if (typeof displayName === "string" && displayName.length > 0)
|
|
602
|
+
body.displayName = displayName;
|
|
603
|
+
if (options.title !== undefined)
|
|
604
|
+
body.title = options.title;
|
|
605
|
+
try {
|
|
606
|
+
return await resources.sessions.createRecord(body);
|
|
607
|
+
}
|
|
608
|
+
catch (error) {
|
|
609
|
+
if (!(error instanceof errors_1.AtlanAPIError) || error.status !== 409)
|
|
610
|
+
throw error;
|
|
611
|
+
const page = await resources.sessions.list({
|
|
612
|
+
limit: 500,
|
|
613
|
+
offset: 0,
|
|
614
|
+
subjectKind,
|
|
615
|
+
subjectId,
|
|
616
|
+
externalSessionId: run.sessionId,
|
|
617
|
+
});
|
|
618
|
+
const items = field(page, "items");
|
|
619
|
+
const matches = (Array.isArray(items) ? items : []).filter((item) => field(item, "workspaceId") === workspaceId &&
|
|
620
|
+
field(item, "subjectKind") === subjectKind &&
|
|
621
|
+
field(item, "subjectId") === subjectId &&
|
|
622
|
+
field(item, "externalSessionId") === run.sessionId);
|
|
623
|
+
if (matches.length === 1)
|
|
624
|
+
return matches[0];
|
|
625
|
+
if (matches.length > 1) {
|
|
626
|
+
throw new Error(`multiple sessions bind experiment ${run.id} to subject ${subjectId}`, { cause: error });
|
|
627
|
+
}
|
|
628
|
+
throw error;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
238
631
|
// ---------------------------------------------------------------------------
|
|
239
632
|
// Declarative evaluator
|
|
240
|
-
const DEFAULT_GATEWAY_ORIGIN = "https://
|
|
633
|
+
const DEFAULT_GATEWAY_ORIGIN = "https://api.atlan.com";
|
|
241
634
|
function tracingModule() {
|
|
242
635
|
// The root client is CommonJS while tracing has its own NodeNext build. A
|
|
243
636
|
// runtime require keeps the two compiler targets independent and resolves to
|
|
@@ -311,6 +704,20 @@ async function collectData(data) {
|
|
|
311
704
|
}
|
|
312
705
|
return cases;
|
|
313
706
|
}
|
|
707
|
+
async function casesDigest(cases) {
|
|
708
|
+
const normalized = cases.map((item, index) => ({
|
|
709
|
+
id: item.id ?? `case-${index + 1}`,
|
|
710
|
+
input: item.input,
|
|
711
|
+
expected: item.expected ?? null,
|
|
712
|
+
metadata: item.metadata ?? {},
|
|
713
|
+
tags: item.tags ?? [],
|
|
714
|
+
}));
|
|
715
|
+
if (new Set(normalized.map((item) => item.id)).size !== cases.length) {
|
|
716
|
+
throw new Error("Eval case IDs must be unique");
|
|
717
|
+
}
|
|
718
|
+
const hash = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonicalJson(normalized)));
|
|
719
|
+
return "sha256:" + [...new Uint8Array(hash)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
720
|
+
}
|
|
314
721
|
async function datasetCases(resources, datasetId) {
|
|
315
722
|
const records = [];
|
|
316
723
|
let offset = 0;
|
|
@@ -394,11 +801,6 @@ async function experimentResults(resources, experimentId) {
|
|
|
394
801
|
}
|
|
395
802
|
return results;
|
|
396
803
|
}
|
|
397
|
-
async function existingCaseIds(resources, experimentId) {
|
|
398
|
-
return new Set((await experimentResults(resources, experimentId))
|
|
399
|
-
.map((item) => field(item, "caseId"))
|
|
400
|
-
.filter((caseId) => typeof caseId === "string" && caseId.length > 0));
|
|
401
|
-
}
|
|
402
804
|
async function findScorer(resources, name, workspaceId) {
|
|
403
805
|
const matches = [];
|
|
404
806
|
let offset = 0;
|
|
@@ -434,6 +836,39 @@ function scorerIdentity(value, workspaceId) {
|
|
|
434
836
|
}
|
|
435
837
|
return { id, version };
|
|
436
838
|
}
|
|
839
|
+
async function scorerDefinition(definition) {
|
|
840
|
+
const outputs = definition.outputs ?? { [definition.name]: { type: "number", minimum: 0, maximum: 1 } };
|
|
841
|
+
if (definition.spec !== undefined)
|
|
842
|
+
return { spec: definition.spec, outputs };
|
|
843
|
+
if (!globalThis.crypto?.subtle)
|
|
844
|
+
throw new Error("Web Crypto is required to identify a scorer");
|
|
845
|
+
const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(String(definition.scorer)));
|
|
846
|
+
const codeDigest = "sha256:" +
|
|
847
|
+
[...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
848
|
+
return {
|
|
849
|
+
outputs,
|
|
850
|
+
spec: {
|
|
851
|
+
executor: "external",
|
|
852
|
+
sdk: "@atlanai/sdk",
|
|
853
|
+
entrypoint: definition.scorer.name || "anonymous",
|
|
854
|
+
code_digest: codeDigest,
|
|
855
|
+
},
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
async function reconcileScorer(resources, existing, workspaceId, definition) {
|
|
859
|
+
const identity = scorerIdentity(existing, workspaceId);
|
|
860
|
+
const scorerKind = definition.scorerKind ?? "code";
|
|
861
|
+
const scope = definition.scope ?? "result";
|
|
862
|
+
if (field(existing, "scorerKind") !== scorerKind || field(existing, "scope") !== scope) {
|
|
863
|
+
throw new Error(`scorer '${definition.name}' reuses a Registry name with a different identity`);
|
|
864
|
+
}
|
|
865
|
+
const desired = await scorerDefinition(definition);
|
|
866
|
+
if (canonicalJson(field(existing, "spec")) === canonicalJson(desired.spec) &&
|
|
867
|
+
canonicalJson(field(existing, "outputs")) === canonicalJson(desired.outputs)) {
|
|
868
|
+
return identity;
|
|
869
|
+
}
|
|
870
|
+
return scorerIdentity(await resources.scorers.update(identity.id, desired), workspaceId);
|
|
871
|
+
}
|
|
437
872
|
async function resolveScorer(resources, workspaceId, evalName, definition) {
|
|
438
873
|
if (!definition.name || /[\r\n]/.test(definition.name)) {
|
|
439
874
|
throw new Error("each scorer needs a non-empty single-line name");
|
|
@@ -448,7 +883,7 @@ async function resolveScorer(resources, workspaceId, evalName, definition) {
|
|
|
448
883
|
if (!Number.isInteger(definition.scorerVersion) || definition.scorerVersion < 1) {
|
|
449
884
|
throw new Error(`scorer '${definition.name}' has an invalid scorerVersion`);
|
|
450
885
|
}
|
|
451
|
-
return
|
|
886
|
+
return scorerIdentity(await resources.scorers.versions.get(identity.id, definition.scorerVersion), workspaceId);
|
|
452
887
|
}
|
|
453
888
|
return identity;
|
|
454
889
|
}
|
|
@@ -457,8 +892,10 @@ async function resolveScorer(resources, workspaceId, evalName, definition) {
|
|
|
457
892
|
}
|
|
458
893
|
const artifactName = definition.registryName ?? `${slug(evalName, "eval")}-${slug(definition.name, "score")}`;
|
|
459
894
|
const existing = await findScorer(resources, artifactName, workspaceId);
|
|
460
|
-
if (existing !== undefined)
|
|
461
|
-
return
|
|
895
|
+
if (existing !== undefined) {
|
|
896
|
+
return reconcileScorer(resources, existing, workspaceId, definition);
|
|
897
|
+
}
|
|
898
|
+
const desired = await scorerDefinition(definition);
|
|
462
899
|
try {
|
|
463
900
|
return scorerIdentity(await resources.scorers.create({
|
|
464
901
|
workspaceId,
|
|
@@ -468,12 +905,8 @@ async function resolveScorer(resources, workspaceId, evalName, definition) {
|
|
|
468
905
|
sourceId: `atlan-eval:${slug(evalName, "eval")}:${slug(definition.name, "score")}`,
|
|
469
906
|
scorerKind: definition.scorerKind ?? "code",
|
|
470
907
|
scope: definition.scope ?? "result",
|
|
471
|
-
outputs:
|
|
472
|
-
spec:
|
|
473
|
-
executor: "external",
|
|
474
|
-
sdk: "@atlanai/sdk",
|
|
475
|
-
entrypoint: definition.scorer.name || "anonymous",
|
|
476
|
-
},
|
|
908
|
+
outputs: desired.outputs,
|
|
909
|
+
spec: desired.spec,
|
|
477
910
|
}), workspaceId);
|
|
478
911
|
}
|
|
479
912
|
catch (error) {
|
|
@@ -482,7 +915,7 @@ async function resolveScorer(resources, workspaceId, evalName, definition) {
|
|
|
482
915
|
const raced = await findScorer(resources, artifactName, workspaceId);
|
|
483
916
|
if (raced === undefined)
|
|
484
917
|
throw error;
|
|
485
|
-
return
|
|
918
|
+
return reconcileScorer(resources, raced, workspaceId, definition);
|
|
486
919
|
}
|
|
487
920
|
}
|
|
488
921
|
function normalizedScores(value, defaultName) {
|
|
@@ -546,12 +979,63 @@ function localSummary(results, durationMs) {
|
|
|
546
979
|
},
|
|
547
980
|
};
|
|
548
981
|
}
|
|
549
|
-
async function
|
|
982
|
+
async function traceSpans(resources, experimentId, traceId) {
|
|
983
|
+
const spans = [];
|
|
984
|
+
const seenCursors = new Set();
|
|
985
|
+
let cursor;
|
|
986
|
+
while (true) {
|
|
987
|
+
const page = await resources.experiments.traces.listSpans({
|
|
988
|
+
experimentId,
|
|
989
|
+
traceId,
|
|
990
|
+
limit: 500,
|
|
991
|
+
// Every check in `missingTraceEvidence` reads a span attribute, and
|
|
992
|
+
// `attributes` is its own projection group: asking for `io` alone
|
|
993
|
+
// returns `attributes: null` on every span, so the gate can only ever
|
|
994
|
+
// fail. `core` carries `spanId`, which the same checks match on.
|
|
995
|
+
fields: "core,io,attributes",
|
|
996
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
997
|
+
});
|
|
998
|
+
const items = field(page, "items");
|
|
999
|
+
if (Array.isArray(items))
|
|
1000
|
+
spans.push(...items);
|
|
1001
|
+
const nextCursor = field(field(page, "page"), "nextCursor");
|
|
1002
|
+
if (typeof nextCursor !== "string" || nextCursor.length === 0)
|
|
1003
|
+
return spans;
|
|
1004
|
+
if (seenCursors.has(nextCursor)) {
|
|
1005
|
+
throw new Error("experiment trace span pagination repeated a cursor");
|
|
1006
|
+
}
|
|
1007
|
+
seenCursors.add(nextCursor);
|
|
1008
|
+
cursor = nextCursor;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
function artifactUnixSeconds(value) {
|
|
1012
|
+
if (typeof value !== "string" || value.length === 0)
|
|
1013
|
+
return undefined;
|
|
1014
|
+
const milliseconds = Date.parse(value);
|
|
1015
|
+
return Number.isFinite(milliseconds) ? Math.floor(milliseconds / 1_000) : undefined;
|
|
1016
|
+
}
|
|
1017
|
+
function experimentTraceWindow(experiment) {
|
|
1018
|
+
const created = artifactUnixSeconds(field(experiment, "createdAt"));
|
|
1019
|
+
const updated = artifactUnixSeconds(field(experiment, "updatedAt"));
|
|
1020
|
+
const startTimeUnixSeconds = Math.max(0, (created ?? 0) - 60 * 60);
|
|
1021
|
+
const endTimeUnixSeconds = field(experiment, "experimentStatus") === "running"
|
|
1022
|
+
? Math.floor(Date.now() / 1_000)
|
|
1023
|
+
: (updated ?? created ?? Math.floor(Date.now() / 1_000)) + 24 * 60 * 60;
|
|
1024
|
+
return { startTimeUnixSeconds, endTimeUnixSeconds };
|
|
1025
|
+
}
|
|
1026
|
+
async function listedTraceIds(resources, experimentId, window) {
|
|
550
1027
|
const traceIds = new Set();
|
|
551
1028
|
const seenCursors = new Set();
|
|
552
1029
|
let cursor;
|
|
1030
|
+
const windowEnd = Math.floor(Date.now() / 1_000);
|
|
1031
|
+
const bounds = window ?? {
|
|
1032
|
+
startTimeUnixSeconds: windowEnd - 30 * 24 * 60 * 60,
|
|
1033
|
+
endTimeUnixSeconds: windowEnd,
|
|
1034
|
+
};
|
|
553
1035
|
while (true) {
|
|
554
|
-
const page = await resources.experiments.traces.list(
|
|
1036
|
+
const page = await resources.experiments.traces.list({
|
|
1037
|
+
experimentId,
|
|
1038
|
+
...bounds,
|
|
555
1039
|
limit: 500,
|
|
556
1040
|
...(cursor === undefined ? {} : { cursor }),
|
|
557
1041
|
});
|
|
@@ -573,14 +1057,106 @@ async function listedTraceIds(resources, experimentId) {
|
|
|
573
1057
|
cursor = nextCursor;
|
|
574
1058
|
}
|
|
575
1059
|
}
|
|
576
|
-
|
|
1060
|
+
/** Rejoin one exploded attribute subtree into dotted keys. */
|
|
1061
|
+
function flattenAttributes(value, prefix, into) {
|
|
1062
|
+
if (isRecord(value)) {
|
|
1063
|
+
for (const [key, child] of Object.entries(value)) {
|
|
1064
|
+
flattenAttributes(child, prefix === "" ? key : `${prefix}.${key}`, into);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
else if (prefix !== "") {
|
|
1068
|
+
into[prefix] = value;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* The span's attributes, keyed the way this module writes them.
|
|
1073
|
+
*
|
|
1074
|
+
* An OTel attribute key is a flat dotted string, and that is what the tracing
|
|
1075
|
+
* side sets. The gateway does not return it that way: it splits the key on
|
|
1076
|
+
* every dot into a nested object and sorts the result into `resource`, `scope`
|
|
1077
|
+
* and `span` envelopes, so `atlan.eval.case_id` arrives as
|
|
1078
|
+
* `{span: {atlan: {eval: {case_id: ...}}}}`. `SpanViewDto.attributes` is
|
|
1079
|
+
* untyped in the contract, so neither shape is wrong; this is the side that has
|
|
1080
|
+
* to adapt. An already-flat mapping is passed through unchanged.
|
|
1081
|
+
*/
|
|
1082
|
+
function spanAttributes(span) {
|
|
1083
|
+
const attributes = field(span, "attributes");
|
|
1084
|
+
if (!isRecord(attributes))
|
|
1085
|
+
return {};
|
|
1086
|
+
const envelopes = ["resource", "scope", "span"].map((name) => attributes[name]);
|
|
1087
|
+
if (!envelopes.some((envelope) => isRecord(envelope)))
|
|
1088
|
+
return attributes;
|
|
1089
|
+
// Span last: a span attribute outranks the resource-wide one it shadows.
|
|
1090
|
+
const flat = {};
|
|
1091
|
+
for (const envelope of envelopes) {
|
|
1092
|
+
if (isRecord(envelope))
|
|
1093
|
+
flattenAttributes(envelope, "", flat);
|
|
1094
|
+
}
|
|
1095
|
+
return flat;
|
|
1096
|
+
}
|
|
1097
|
+
function missingTraceEvidence(spans, expected) {
|
|
1098
|
+
const missing = [];
|
|
1099
|
+
const byId = new Map();
|
|
1100
|
+
for (const span of spans) {
|
|
1101
|
+
const spanId = field(span, "spanId");
|
|
1102
|
+
if (typeof spanId === "string")
|
|
1103
|
+
byId.set(spanId, span);
|
|
1104
|
+
}
|
|
1105
|
+
const root = expected.rootSpanId === undefined
|
|
1106
|
+
? spans.find((span) => spanAttributes(span)["atlan.eval.case_id"] === expected.caseId &&
|
|
1107
|
+
spanAttributes(span)["atlan.eval.evidence_complete"] === true)
|
|
1108
|
+
: byId.get(expected.rootSpanId);
|
|
1109
|
+
if (root === undefined) {
|
|
1110
|
+
missing.push("case root");
|
|
1111
|
+
}
|
|
1112
|
+
else {
|
|
1113
|
+
const attributes = spanAttributes(root);
|
|
1114
|
+
if (attributes["atlan.eval.evidence_complete"] !== true) {
|
|
1115
|
+
missing.push("producer completion marker");
|
|
1116
|
+
}
|
|
1117
|
+
if (attributes["atlan.eval.case_id"] !== expected.caseId)
|
|
1118
|
+
missing.push("case identity");
|
|
1119
|
+
if (expected.datasetRecordId !== undefined &&
|
|
1120
|
+
attributes["atlan.eval.dataset_record_id"] !== expected.datasetRecordId) {
|
|
1121
|
+
missing.push("dataset record identity");
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
const absentIds = [...expected.requiredSpanIds].filter((spanId) => !byId.has(spanId));
|
|
1125
|
+
if (absentIds.length > 0)
|
|
1126
|
+
missing.push(`${absentIds.length} SDK-owned spans`);
|
|
1127
|
+
if (spans.some((span) => spanAttributes(span)["atlan.eval.evidence_truncated"] === true)) {
|
|
1128
|
+
missing.push("untruncated payloads");
|
|
1129
|
+
}
|
|
1130
|
+
for (const [scoreName, scorer] of expected.scores) {
|
|
1131
|
+
const present = spans.some((span) => field(span, "scoreName") === scoreName &&
|
|
1132
|
+
field(span, "scorerId") === scorer.id &&
|
|
1133
|
+
field(span, "scorerVersion") === scorer.version);
|
|
1134
|
+
if (!present)
|
|
1135
|
+
missing.push(`score '${scoreName}'`);
|
|
1136
|
+
}
|
|
1137
|
+
return missing;
|
|
1138
|
+
}
|
|
1139
|
+
async function verifyTraceEvidence(resources, experimentId, expected, timeoutMs) {
|
|
577
1140
|
const deadline = Date.now() + timeoutMs;
|
|
578
|
-
let missing =
|
|
1141
|
+
let missing = new Map();
|
|
579
1142
|
let lastError;
|
|
580
1143
|
while (true) {
|
|
581
1144
|
try {
|
|
582
|
-
|
|
583
|
-
|
|
1145
|
+
missing = new Map();
|
|
1146
|
+
if (typeof resources.experiments.traces.listSpans === "function") {
|
|
1147
|
+
for (const [traceId, expectation] of expected) {
|
|
1148
|
+
const gaps = missingTraceEvidence(await traceSpans(resources, experimentId, traceId), expectation);
|
|
1149
|
+
if (gaps.length > 0)
|
|
1150
|
+
missing.set(traceId, gaps);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
else {
|
|
1154
|
+
const observed = await listedTraceIds(resources, experimentId);
|
|
1155
|
+
for (const traceId of expected.keys()) {
|
|
1156
|
+
if (!observed.has(traceId))
|
|
1157
|
+
missing.set(traceId, ["trace read"]);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
584
1160
|
lastError = undefined;
|
|
585
1161
|
}
|
|
586
1162
|
catch (error) {
|
|
@@ -588,15 +1164,16 @@ async function verifyTraceJoins(resources, experimentId, expected, timeoutMs) {
|
|
|
588
1164
|
![404, 429, 500, 502, 503, 504].includes(error.status)) {
|
|
589
1165
|
throw error;
|
|
590
1166
|
}
|
|
591
|
-
missing = expected;
|
|
1167
|
+
missing = new Map([...expected.keys()].map((traceId) => [traceId, ["trace read"]]));
|
|
592
1168
|
lastError = error;
|
|
593
1169
|
}
|
|
594
1170
|
if (missing.size === 0)
|
|
595
1171
|
return;
|
|
596
1172
|
const remaining = deadline - Date.now();
|
|
597
1173
|
if (remaining <= 0) {
|
|
598
|
-
const failure = new Error(`Registry did not expose ${missing.size} of
|
|
599
|
-
|
|
1174
|
+
const failure = new Error(`Registry did not expose complete trace evidence for ${missing.size} of ` +
|
|
1175
|
+
`${expected.size} case traces before the verification deadline: ` +
|
|
1176
|
+
[...missing].map(([traceId, gaps]) => `${traceId} (${gaps.join(", ")})`).join("; "));
|
|
600
1177
|
if (lastError !== undefined)
|
|
601
1178
|
failure.cause = lastError;
|
|
602
1179
|
throw failure;
|
|
@@ -632,6 +1209,11 @@ function defaultConnections(name, options) {
|
|
|
632
1209
|
if (sampleRate !== undefined && sampleRate !== 1) {
|
|
633
1210
|
throw new Error("Eval tracing requires sampleRate=1 so no case trace is sampled out");
|
|
634
1211
|
}
|
|
1212
|
+
const traceContent = traceLogger.client.config
|
|
1213
|
+
?.traceContent;
|
|
1214
|
+
if (traceContent === false) {
|
|
1215
|
+
throw new Error("Eval tracing requires traceContent=true so case evidence is readable");
|
|
1216
|
+
}
|
|
635
1217
|
return { client, logger: traceLogger, workspaceId, tracing };
|
|
636
1218
|
}
|
|
637
1219
|
/**
|
|
@@ -642,6 +1224,73 @@ function defaultConnections(name, options) {
|
|
|
642
1224
|
* carry the root trace ID, traces flush before summary, and the experiment is
|
|
643
1225
|
* explicitly finalized.
|
|
644
1226
|
*/
|
|
1227
|
+
// The Registry resource each subject kind lives in.
|
|
1228
|
+
const SUBJECT_RESOURCES = {
|
|
1229
|
+
agent: "agents",
|
|
1230
|
+
harness: "harnesses",
|
|
1231
|
+
skill: "skills",
|
|
1232
|
+
};
|
|
1233
|
+
/**
|
|
1234
|
+
* Refuse an experiment whose subject lives in another workspace.
|
|
1235
|
+
*
|
|
1236
|
+
* The dataset is already checked ("dataset ID resolved outside the requested
|
|
1237
|
+
* workspace") and so is every scorer ("scorer resolved outside the evaluation
|
|
1238
|
+
* workspace"). The subject was not, and it is the field most likely to be
|
|
1239
|
+
* wrong: the listings people resolve a subject's name through are not
|
|
1240
|
+
* workspace-scoped, so `agents.list()` answers with agents from every workspace
|
|
1241
|
+
* the caller can see and "reuse the agent called X" quietly picks a stranger's.
|
|
1242
|
+
* The gateway accepts the row, and the run is then attributed to an agent its
|
|
1243
|
+
* own workspace has never heard of.
|
|
1244
|
+
*
|
|
1245
|
+
* A read that fails is not proof of a mismatch, so only a subject that reads
|
|
1246
|
+
* back with a different workspace fails the run. This is a wrong-binding check,
|
|
1247
|
+
* not an authorization gate.
|
|
1248
|
+
*/
|
|
1249
|
+
async function checkSubjectWorkspace(client, subjectKind, subjectId, workspaceId) {
|
|
1250
|
+
const resourceName = SUBJECT_RESOURCES[subjectKind ?? ""];
|
|
1251
|
+
if (resourceName === undefined || subjectId === undefined || subjectId.length === 0)
|
|
1252
|
+
return;
|
|
1253
|
+
const resource = field(client, resourceName);
|
|
1254
|
+
const getter = field(resource, "get");
|
|
1255
|
+
if (typeof getter !== "function")
|
|
1256
|
+
return;
|
|
1257
|
+
let subject;
|
|
1258
|
+
try {
|
|
1259
|
+
subject = await getter.call(resource, subjectId);
|
|
1260
|
+
}
|
|
1261
|
+
catch {
|
|
1262
|
+
// See the doc comment: a read that fails is not proof of a mismatch.
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
const actual = field(subject, "workspaceId");
|
|
1266
|
+
if (actual !== undefined && actual !== workspaceId) {
|
|
1267
|
+
throw new Error(`subject ${subjectId} lives in workspace ${String(actual)}, not ${workspaceId}; ` +
|
|
1268
|
+
`an experiment cannot be attributed to a subject outside its own workspace`);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
function nameOpenExperiment(error, experimentId) {
|
|
1272
|
+
if (!(error instanceof Error))
|
|
1273
|
+
return error;
|
|
1274
|
+
const note = `experiment ${experimentId} was left running; pass ` +
|
|
1275
|
+
`resumeExperimentId: ${JSON.stringify(experimentId)} to continue it`;
|
|
1276
|
+
try {
|
|
1277
|
+
Object.defineProperty(error, "experimentId", {
|
|
1278
|
+
configurable: true,
|
|
1279
|
+
enumerable: true,
|
|
1280
|
+
value: experimentId,
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
catch {
|
|
1284
|
+
// Preserve the original error even when a caller froze it.
|
|
1285
|
+
}
|
|
1286
|
+
try {
|
|
1287
|
+
error.message = `${error.message} (${note})`;
|
|
1288
|
+
}
|
|
1289
|
+
catch {
|
|
1290
|
+
// Preserve the original error even when its message is read-only.
|
|
1291
|
+
}
|
|
1292
|
+
return error;
|
|
1293
|
+
}
|
|
645
1294
|
async function Eval(name, evaluator, options = {}) {
|
|
646
1295
|
if (!name || /[\r\n]/.test(name))
|
|
647
1296
|
throw new Error("Eval name must be non-empty and single-line");
|
|
@@ -679,6 +1328,7 @@ async function Eval(name, evaluator, options = {}) {
|
|
|
679
1328
|
if (value === undefined)
|
|
680
1329
|
delete experimentBody[key];
|
|
681
1330
|
}
|
|
1331
|
+
await checkSubjectWorkspace(client, options.subjectKind, options.subjectId, workspaceId);
|
|
682
1332
|
let run;
|
|
683
1333
|
let cases = [];
|
|
684
1334
|
if (options.resumeExperimentId !== undefined) {
|
|
@@ -689,11 +1339,14 @@ async function Eval(name, evaluator, options = {}) {
|
|
|
689
1339
|
if (field(experiment, "experimentStatus") !== "running") {
|
|
690
1340
|
throw new Error("resumeExperimentId must name a running experiment");
|
|
691
1341
|
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
options.contextManifest.digest) {
|
|
1342
|
+
const storedConfig = field(experiment, "config") ?? {};
|
|
1343
|
+
if (field(storedConfig, "context_manifest_digest") !== options.contextManifest?.digest) {
|
|
695
1344
|
throw new Error("resume experiment uses a different context manifest");
|
|
696
1345
|
}
|
|
1346
|
+
const storedUserConfig = Object.fromEntries(Object.entries(storedConfig).filter(([key]) => !["runner", "registry_dataset", "context_manifest", "context_manifest_digest", "eval_cases_digest"].includes(key)));
|
|
1347
|
+
if (canonicalJson(storedUserConfig) !== canonicalJson(options.config ?? {})) {
|
|
1348
|
+
throw new Error("resume experiment uses a different config");
|
|
1349
|
+
}
|
|
697
1350
|
let resolvedDataset;
|
|
698
1351
|
if (evaluator.dataset !== undefined) {
|
|
699
1352
|
resolvedDataset = await resolveDataset(client, evaluator.dataset, workspaceId);
|
|
@@ -706,6 +1359,9 @@ async function Eval(name, evaluator, options = {}) {
|
|
|
706
1359
|
}
|
|
707
1360
|
else {
|
|
708
1361
|
cases = await collectData(evaluator.data);
|
|
1362
|
+
if (field(storedConfig, "eval_cases_digest") !== await casesDigest(cases)) {
|
|
1363
|
+
throw new Error("resume inline data differs or has no saved digest; start a new experiment");
|
|
1364
|
+
}
|
|
709
1365
|
}
|
|
710
1366
|
run = new EvalRun(experiment, resolvedDataset, options.contextManifest);
|
|
711
1367
|
}
|
|
@@ -717,6 +1373,7 @@ async function Eval(name, evaluator, options = {}) {
|
|
|
717
1373
|
else {
|
|
718
1374
|
cases = await collectData(evaluator.data);
|
|
719
1375
|
const config = { ...experimentBody.config };
|
|
1376
|
+
config.eval_cases_digest = await casesDigest(cases);
|
|
720
1377
|
if (options.contextManifest !== undefined) {
|
|
721
1378
|
config.context_manifest = options.contextManifest;
|
|
722
1379
|
config.context_manifest_digest = options.contextManifest.digest;
|
|
@@ -729,23 +1386,42 @@ async function Eval(name, evaluator, options = {}) {
|
|
|
729
1386
|
}
|
|
730
1387
|
}
|
|
731
1388
|
const scorerCache = new Map();
|
|
1389
|
+
const traceEvidence = new Map();
|
|
732
1390
|
const started = Date.now();
|
|
733
1391
|
const results = [];
|
|
734
1392
|
try {
|
|
735
1393
|
options.onStart?.({ experimentId: run.id, experiment: run.experiment });
|
|
736
1394
|
if (evaluator.dataset !== undefined) {
|
|
1395
|
+
const snapshot = snapshotCases(run.experiment);
|
|
1396
|
+
if (options.resumeExperimentId !== undefined && snapshot === undefined) {
|
|
1397
|
+
throw new Error("resume requires the experiment's immutable dataset snapshot");
|
|
1398
|
+
}
|
|
737
1399
|
cases =
|
|
738
|
-
|
|
1400
|
+
snapshot ??
|
|
739
1401
|
(await datasetCases(resources, String(field(run.dataset, "id"))));
|
|
740
1402
|
}
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
1403
|
+
if (evaluator.dataset !== undefined && cases.length === 0) {
|
|
1404
|
+
// A named dataset that yields nothing is a mistake upstream, not an eval
|
|
1405
|
+
// with no work in it. Finishing here would write a `completed`
|
|
1406
|
+
// experiment carrying no results and no scores, which reads downstream
|
|
1407
|
+
// as a run that happened and found nothing.
|
|
1408
|
+
throw new Error(`dataset ${JSON.stringify(evaluator.dataset)} resolved to ` +
|
|
1409
|
+
`${String(field(run.dataset, "id"))} and it has no records, so there is nothing to evaluate`);
|
|
1410
|
+
}
|
|
1411
|
+
const caseIds = new Set(cases.map((item, index) => item.id ?? `case-${index + 1}`));
|
|
1412
|
+
if (caseIds.size !== cases.length)
|
|
1413
|
+
throw new Error("Eval case IDs must be unique");
|
|
1414
|
+
const persisted = options.resumeExperimentId === undefined ? [] : await experimentResults(resources, run.id);
|
|
1415
|
+
const completedCaseIds = new Set(persisted.map((item) => field(item, "caseId")));
|
|
1416
|
+
if (completedCaseIds.size !== persisted.length || [...completedCaseIds].some((id) => typeof id !== "string" || !caseIds.has(id))) {
|
|
1417
|
+
throw new Error("resume experiment contains unexpected or duplicate case IDs");
|
|
1418
|
+
}
|
|
744
1419
|
for (const definition of evaluator.scores) {
|
|
745
1420
|
scorerCache.set(definition.name, await resolveScorer(resources, workspaceId, name, definition));
|
|
746
1421
|
}
|
|
747
1422
|
for (const [index, item] of cases.entries()) {
|
|
748
1423
|
const caseId = item.id ?? `case-${index + 1}`;
|
|
1424
|
+
const datasetRecordId = evaluator.dataset === undefined ? undefined : item.id;
|
|
749
1425
|
if (completedCaseIds.has(caseId))
|
|
750
1426
|
continue;
|
|
751
1427
|
const caseStarted = Date.now();
|
|
@@ -753,8 +1429,15 @@ async function Eval(name, evaluator, options = {}) {
|
|
|
753
1429
|
const failures = [];
|
|
754
1430
|
let output;
|
|
755
1431
|
let traceId = "";
|
|
1432
|
+
let rootSpanId;
|
|
1433
|
+
const requiredSpanIds = new Set();
|
|
1434
|
+
const scoreEvidence = new Map();
|
|
756
1435
|
const metadata = (item.metadata ?? {});
|
|
757
|
-
await tracing.propagateAttributes(
|
|
1436
|
+
await tracing.propagateAttributes({
|
|
1437
|
+
...run.traceOptions,
|
|
1438
|
+
caseId,
|
|
1439
|
+
...(datasetRecordId === undefined ? {} : { datasetRecordId }),
|
|
1440
|
+
}, () => traceLogger.client.startAsCurrentSpan(`eval.case.${index + 1}`, {
|
|
758
1441
|
asType: "task",
|
|
759
1442
|
fields: {
|
|
760
1443
|
input: item.input,
|
|
@@ -763,8 +1446,13 @@ async function Eval(name, evaluator, options = {}) {
|
|
|
763
1446
|
},
|
|
764
1447
|
}, async (rootSpan) => {
|
|
765
1448
|
traceId = rootSpan.traceId;
|
|
1449
|
+
rootSpanId = rootSpan.spanId;
|
|
1450
|
+
if (rootSpan.spanId !== undefined)
|
|
1451
|
+
requiredSpanIds.add(rootSpan.spanId);
|
|
766
1452
|
try {
|
|
767
1453
|
output = await traceLogger.client.startAsCurrentSpan("eval.task", { asType: "task", fields: { input: item.input } }, async (taskSpan) => {
|
|
1454
|
+
if (taskSpan.spanId !== undefined)
|
|
1455
|
+
requiredSpanIds.add(taskSpan.spanId);
|
|
768
1456
|
const value = await evaluator.task(item.input, {
|
|
769
1457
|
expected: item.expected,
|
|
770
1458
|
metadata,
|
|
@@ -779,6 +1467,7 @@ async function Eval(name, evaluator, options = {}) {
|
|
|
779
1467
|
catch (error) {
|
|
780
1468
|
failures.push(safeError(error));
|
|
781
1469
|
rootSpan.recordFailure(error);
|
|
1470
|
+
rootSpan.markEvidenceComplete?.();
|
|
782
1471
|
return;
|
|
783
1472
|
}
|
|
784
1473
|
for (const definition of evaluator.scores) {
|
|
@@ -793,6 +1482,8 @@ async function Eval(name, evaluator, options = {}) {
|
|
|
793
1482
|
},
|
|
794
1483
|
},
|
|
795
1484
|
}, async (scoreSpan) => {
|
|
1485
|
+
if (scoreSpan.spanId !== undefined)
|
|
1486
|
+
requiredSpanIds.add(scoreSpan.spanId);
|
|
796
1487
|
const value = await definition.scorer({ ...item, output: output });
|
|
797
1488
|
scoreSpan.update({ output: value });
|
|
798
1489
|
return value;
|
|
@@ -813,6 +1504,7 @@ async function Eval(name, evaluator, options = {}) {
|
|
|
813
1504
|
}
|
|
814
1505
|
const numeric = numericScore(scored.score);
|
|
815
1506
|
scores[scoreName] = numeric;
|
|
1507
|
+
scoreEvidence.set(scoreName, registered);
|
|
816
1508
|
rootSpan.score(scoreName, numeric, {
|
|
817
1509
|
scorerId: registered.id,
|
|
818
1510
|
scorerVersion: registered.version,
|
|
@@ -828,6 +1520,7 @@ async function Eval(name, evaluator, options = {}) {
|
|
|
828
1520
|
}
|
|
829
1521
|
}
|
|
830
1522
|
rootSpan.update({ output, metadata });
|
|
1523
|
+
rootSpan.markEvidenceComplete?.();
|
|
831
1524
|
}));
|
|
832
1525
|
if (!TRACE_ID.test(traceId) || /^0+$/.test(traceId)) {
|
|
833
1526
|
throw new Error(`case ${index + 1} did not produce a trace ID`);
|
|
@@ -841,41 +1534,99 @@ async function Eval(name, evaluator, options = {}) {
|
|
|
841
1534
|
traceId,
|
|
842
1535
|
durationMs: Date.now() - caseStarted,
|
|
843
1536
|
});
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
1537
|
+
traceEvidence.set(traceId, {
|
|
1538
|
+
caseId,
|
|
1539
|
+
...(datasetRecordId === undefined ? {} : { datasetRecordId }),
|
|
1540
|
+
...(rootSpanId === undefined ? {} : { rootSpanId }),
|
|
1541
|
+
requiredSpanIds,
|
|
1542
|
+
scores: scoreEvidence,
|
|
1543
|
+
});
|
|
1544
|
+
// Persist each completed case before starting another costly task.
|
|
1545
|
+
// Keep the source index so a resumed subset cannot reuse earlier names.
|
|
1546
|
+
const result = results[results.length - 1];
|
|
1547
|
+
await traceLogger.flush();
|
|
1548
|
+
if (options.verifyTraces !== false) {
|
|
1549
|
+
await verifyTraceEvidence(resources, run.id, new Map([[result.traceId, traceEvidence.get(result.traceId)]]), traceVerificationTimeoutMs);
|
|
1550
|
+
}
|
|
1551
|
+
const resultBody = {
|
|
1552
|
+
workspaceId,
|
|
1553
|
+
name: `${slug(run.id, "experiment")}-case-${index + 1}`,
|
|
1554
|
+
caseId: result.caseId,
|
|
1555
|
+
datasetRecordId,
|
|
1556
|
+
input: registryObject(result.input),
|
|
1557
|
+
expected: registryObject(result.expected),
|
|
1558
|
+
output: registryObject(result.output),
|
|
1559
|
+
error: result.error,
|
|
1560
|
+
scores: result.scores,
|
|
1561
|
+
traceId: result.traceId,
|
|
1562
|
+
durationMs: result.durationMs,
|
|
1563
|
+
sourceType: "local",
|
|
1564
|
+
};
|
|
865
1565
|
const response = await resources.experiments.results.createBulk(run.id, {
|
|
866
|
-
items:
|
|
1566
|
+
items: [resultBody],
|
|
867
1567
|
});
|
|
868
1568
|
const slots = field(response, "items");
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
return typeof status === "number" && status >= 400;
|
|
873
|
-
});
|
|
874
|
-
if (failed.length > 0)
|
|
875
|
-
throw new Error(`${failed.length} Eval results were rejected`);
|
|
1569
|
+
const status = Array.isArray(slots) && slots.length === 1 ? field(slots[0], "statusCode") : undefined;
|
|
1570
|
+
if (typeof status !== "number" || status < 200 || status >= 300) {
|
|
1571
|
+
throw new Error("1 Eval results were rejected or not acknowledged");
|
|
876
1572
|
}
|
|
877
1573
|
}
|
|
878
1574
|
const persistedResults = await experimentResults(resources, run.id);
|
|
1575
|
+
const persistedCaseIds = new Set(persistedResults.map((item) => field(item, "caseId")));
|
|
1576
|
+
if (persistedResults.length !== caseIds.size || persistedCaseIds.size !== caseIds.size || [...caseIds].some((id) => !persistedCaseIds.has(id))) {
|
|
1577
|
+
throw new Error("Registry result coverage does not match the evaluation cases");
|
|
1578
|
+
}
|
|
1579
|
+
if (options.verifyTraces !== false && persistedResults.length > 0) {
|
|
1580
|
+
const traceIds = new Set();
|
|
1581
|
+
for (const item of persistedResults) {
|
|
1582
|
+
const traceId = field(item, "traceId");
|
|
1583
|
+
if (typeof traceId !== "string" || !TRACE_ID.test(traceId) || /^0+$/.test(traceId)) {
|
|
1584
|
+
throw new Error("Registry result is missing a valid trace ID");
|
|
1585
|
+
}
|
|
1586
|
+
traceIds.add(traceId);
|
|
1587
|
+
}
|
|
1588
|
+
const expectations = new Map();
|
|
1589
|
+
for (const item of persistedResults) {
|
|
1590
|
+
const traceId = field(item, "traceId");
|
|
1591
|
+
const current = traceEvidence.get(traceId);
|
|
1592
|
+
if (current !== undefined) {
|
|
1593
|
+
expectations.set(traceId, current);
|
|
1594
|
+
continue;
|
|
1595
|
+
}
|
|
1596
|
+
const persistedScores = field(item, "scores");
|
|
1597
|
+
const scorePins = new Map();
|
|
1598
|
+
if (isRecord(persistedScores)) {
|
|
1599
|
+
for (const [scoreName, value] of Object.entries(persistedScores)) {
|
|
1600
|
+
const scorer = scorerCache.get(scoreName);
|
|
1601
|
+
if (value !== null && scorer !== undefined)
|
|
1602
|
+
scorePins.set(scoreName, scorer);
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
const datasetRecordId = field(item, "datasetRecordId");
|
|
1606
|
+
expectations.set(traceId, {
|
|
1607
|
+
caseId: String(field(item, "caseId")),
|
|
1608
|
+
...(typeof datasetRecordId === "string" ? { datasetRecordId } : {}),
|
|
1609
|
+
requiredSpanIds: new Set(),
|
|
1610
|
+
scores: scorePins,
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
await verifyTraceEvidence(resources, run.id, expectations, traceVerificationTimeoutMs);
|
|
1614
|
+
}
|
|
1615
|
+
// Without this the traces are readable by experiment but nothing on the
|
|
1616
|
+
// subject leads to them. Bind before the terminal update so a failed
|
|
1617
|
+
// create leaves a resumable experiment instead of a completed one with no
|
|
1618
|
+
// route from its subject to the traces.
|
|
1619
|
+
let session;
|
|
1620
|
+
try {
|
|
1621
|
+
session = await recordExperimentSession(client, run);
|
|
1622
|
+
}
|
|
1623
|
+
catch (error) {
|
|
1624
|
+
throw new Error(`experiment ${run.id} has every result persisted, but recording ` +
|
|
1625
|
+
`the session that binds its traces to subject ` +
|
|
1626
|
+
`${String(field(run.experiment, "subjectId"))} failed: ${safeError(error)}. ` +
|
|
1627
|
+
`The traces are readable with experimentTraces(client, "${run.id}"); the ` +
|
|
1628
|
+
`experiment remains running and can be resumed safely.`, { cause: error });
|
|
1629
|
+
}
|
|
879
1630
|
const local = localSummary(persistedResults, Date.now() - started);
|
|
880
1631
|
const experiment = await resources.experiments.update(run.id, {
|
|
881
1632
|
experimentStatus: "completed",
|
|
@@ -891,6 +1642,8 @@ async function Eval(name, evaluator, options = {}) {
|
|
|
891
1642
|
dataset: run.dataset,
|
|
892
1643
|
summary,
|
|
893
1644
|
results: options.returnResults === false ? [] : results,
|
|
1645
|
+
sessionId: run.sessionId,
|
|
1646
|
+
...(session === undefined ? {} : { session }),
|
|
894
1647
|
};
|
|
895
1648
|
}
|
|
896
1649
|
catch (error) {
|
|
@@ -902,15 +1655,193 @@ async function Eval(name, evaluator, options = {}) {
|
|
|
902
1655
|
catch {
|
|
903
1656
|
// Preserve the original runner/upload failure.
|
|
904
1657
|
}
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
1658
|
+
// Leave interrupted infrastructure work running: terminal experiments
|
|
1659
|
+
// cannot accept more results. Resume reconciles Registry case IDs.
|
|
1660
|
+
throw nameOpenExperiment(error, run.id);
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
/**
|
|
1664
|
+
* A span's score column, from the typed projection or the raw attribute.
|
|
1665
|
+
*
|
|
1666
|
+
* The gateway promotes `atlan.score.*` onto typed columns, but only when it was
|
|
1667
|
+
* asked for the projection that carries them. Falling back to the attribute
|
|
1668
|
+
* keeps this working on spans read any other way.
|
|
1669
|
+
*/
|
|
1670
|
+
function promoted(span, name, attribute) {
|
|
1671
|
+
const value = field(span, name);
|
|
1672
|
+
return value === undefined || value === null
|
|
1673
|
+
? spanAttributes(span)[`atlan.score.${attribute}`]
|
|
1674
|
+
: value;
|
|
1675
|
+
}
|
|
1676
|
+
/**
|
|
1677
|
+
* True only for a span that actually carries a score verdict.
|
|
1678
|
+
*
|
|
1679
|
+
* Span type is not the test. `startAsCurrentSpan({asType: "score"})` produces a
|
|
1680
|
+
* `score`-typed wrapper around the real verdict, and the gateway answers for
|
|
1681
|
+
* that wrapper with `scoreName: ""`, `scoreValue: 0`, `scorerId: ""`,
|
|
1682
|
+
* `scorerVersion: 0` rather than omitting the columns. Read it as evidence and
|
|
1683
|
+
* an unscored wrapper becomes "scored 0 by nobody". Only a non-empty
|
|
1684
|
+
* `scoreName` marks a span a scorer actually wrote.
|
|
1685
|
+
*/
|
|
1686
|
+
function isScoreVerdict(span) {
|
|
1687
|
+
const name = promoted(span, "scoreName", "score_name");
|
|
1688
|
+
return typeof name === "string" && name.trim().length > 0;
|
|
1689
|
+
}
|
|
1690
|
+
/**
|
|
1691
|
+
* Run the documented seven-point evidence gate over a finished experiment.
|
|
1692
|
+
*
|
|
1693
|
+
* Reads the experiment, its result rows, its trace list and the spans of up to
|
|
1694
|
+
* `maxTraces` case traces, and reports whether the evidence chain actually
|
|
1695
|
+
* holds. It only reads; nothing is written.
|
|
1696
|
+
*
|
|
1697
|
+
* Gate 7, the content and masking policy, cannot be decided from the data and
|
|
1698
|
+
* is reported as a manual check rather than silently passed.
|
|
1699
|
+
*/
|
|
1700
|
+
async function verifyExperiment(client, experimentId, options = {}) {
|
|
1701
|
+
if (experimentId.length === 0 || experimentId !== experimentId.trim()) {
|
|
1702
|
+
throw new Error("experimentId must be a non-empty ID with no outer whitespace");
|
|
1703
|
+
}
|
|
1704
|
+
const resources = client;
|
|
1705
|
+
const maxTraces = options.maxTraces === undefined ? 3 : options.maxTraces;
|
|
1706
|
+
const experiment = await resources.experiments.get(experimentId);
|
|
1707
|
+
const results = await experimentResults(resources, experimentId);
|
|
1708
|
+
const checks = [];
|
|
1709
|
+
const record = (gate, name, ok, detail = "") => {
|
|
1710
|
+
checks.push({ gate, name, ok, detail });
|
|
1711
|
+
};
|
|
1712
|
+
// Gate 1 -------------------------------------------------------------------
|
|
1713
|
+
const status = field(experiment, "experimentStatus");
|
|
1714
|
+
record(1, "the experiment reached a terminal status", status === "completed" || status === "failed", `experimentStatus=${JSON.stringify(status)}; 'running' after a finished run ` +
|
|
1715
|
+
`is a lifecycle failure, not a slow write`);
|
|
1716
|
+
// Gate 2 -------------------------------------------------------------------
|
|
1717
|
+
record(2, "at least one result row was written", results.length > 0, `${results.length} rows`);
|
|
1718
|
+
if (options.expectedCaseCount !== undefined) {
|
|
1719
|
+
record(2, "the result count equals the case count", results.length === options.expectedCaseCount, `${results.length} rows for ${options.expectedCaseCount} cases`);
|
|
1720
|
+
}
|
|
1721
|
+
const caseIds = results.map((row) => field(row, "caseId") ?? field(row, "name"));
|
|
1722
|
+
const duplicates = [
|
|
1723
|
+
...new Set(caseIds.filter((key) => caseIds.filter((other) => other === key).length > 1)),
|
|
1724
|
+
];
|
|
1725
|
+
record(2, "no duplicate result rows", duplicates.length === 0, duplicates.length === 0 ? "" : `repeated case ids: ${duplicates.join(", ")}`);
|
|
1726
|
+
// Gate 3 -------------------------------------------------------------------
|
|
1727
|
+
const rawTraceIds = results.map((row) => field(row, "traceId"));
|
|
1728
|
+
const malformed = rawTraceIds.filter((value) => typeof value !== "string" || !TRACE_ID.test(value));
|
|
1729
|
+
record(3, "every result carries a W3C trace id", malformed.length === 0, malformed.length === 0 ? "" : `${malformed.length} malformed trace ids`);
|
|
1730
|
+
const zeroed = rawTraceIds.filter((value) => typeof value === "string" && /^0+$/.test(value));
|
|
1731
|
+
record(3, "no result carries the all-zero trace id", zeroed.length === 0, zeroed.length === 0
|
|
1732
|
+
? ""
|
|
1733
|
+
: `${zeroed.length} rows read as 'a trace that exists and is empty' rather ` +
|
|
1734
|
+
`than 'this case was not traced'`);
|
|
1735
|
+
record(3, "trace ids are distinct per case", new Set(rawTraceIds).size === rawTraceIds.length, `${rawTraceIds.length - new Set(rawTraceIds).size} rows share a trace with another row`);
|
|
1736
|
+
// Gate 4 -------------------------------------------------------------------
|
|
1737
|
+
let listed = new Set();
|
|
1738
|
+
let traceListReadable = true;
|
|
1739
|
+
try {
|
|
1740
|
+
listed = await listedTraceIds(resources, experimentId, experimentTraceWindow(experiment));
|
|
1741
|
+
}
|
|
1742
|
+
catch (error) {
|
|
1743
|
+
traceListReadable = false;
|
|
1744
|
+
record(4, "the experiment trace filter is readable", false, String(error));
|
|
1745
|
+
}
|
|
1746
|
+
if (traceListReadable) {
|
|
1747
|
+
// An empty listing is a real answer here, not a missing one: it means every
|
|
1748
|
+
// result row points at a trace the experiment does not own.
|
|
1749
|
+
const missing = rawTraceIds.filter((value) => typeof value === "string" && !listed.has(value));
|
|
1750
|
+
record(4, "every result trace id appears in the experiment trace list", missing.length === 0, missing.length === 0
|
|
1751
|
+
? `${listed.size} traces listed`
|
|
1752
|
+
: `${missing.length} results point at a trace the experiment filter does not return`);
|
|
1753
|
+
}
|
|
1754
|
+
// Gates 5 and 6 ------------------------------------------------------------
|
|
1755
|
+
const wellFormed = rawTraceIds.filter((value) => typeof value === "string" && TRACE_ID.test(value));
|
|
1756
|
+
const sampled = maxTraces === null ? wellFormed : wellFormed.slice(0, maxTraces);
|
|
1757
|
+
const verified = [];
|
|
1758
|
+
for (const traceId of sampled) {
|
|
1759
|
+
const spans = await traceSpans(resources, experimentId, traceId);
|
|
1760
|
+
verified.push(traceId);
|
|
1761
|
+
const label = `trace ${traceId}`;
|
|
1762
|
+
if (spans.length === 0) {
|
|
1763
|
+
record(5, `${label} has spans`, false, "0 spans returned");
|
|
1764
|
+
continue;
|
|
913
1765
|
}
|
|
914
|
-
|
|
1766
|
+
const verdicts = spans.filter(isScoreVerdict);
|
|
1767
|
+
const typedScore = spans.filter((span) => field(span, "spanType") === "score" ||
|
|
1768
|
+
spanAttributes(span)["atlan.span.type"] === "score");
|
|
1769
|
+
record(5, `${label} carries a score verdict`, verdicts.length > 0, verdicts.length > 0
|
|
1770
|
+
? `${verdicts.length} of ${spans.length} spans carry a verdict`
|
|
1771
|
+
: typedScore.length > 0
|
|
1772
|
+
? `${typedScore.length} spans are typed \`score\` but none carries a ` +
|
|
1773
|
+
`scoreName, so none of them is evidence`
|
|
1774
|
+
: "no scorer wrote a span on this trace");
|
|
1775
|
+
const hollow = typedScore.filter((span) => !verdicts.includes(span));
|
|
1776
|
+
record(5, `${label} has no hollow \`score\` spans`, hollow.length === 0, hollow.length === 0
|
|
1777
|
+
? ""
|
|
1778
|
+
: `${hollow.length} spans are typed \`score\` with an empty scoreName; the ` +
|
|
1779
|
+
`gateway answers for those with scoreValue 0, which reads as a real ` +
|
|
1780
|
+
`zero to anything that does not filter them out`);
|
|
1781
|
+
const unpinned = verdicts.filter((span) => {
|
|
1782
|
+
const scorerId = promoted(span, "scorerId", "scorer_id");
|
|
1783
|
+
return typeof scorerId !== "string" || scorerId.length === 0;
|
|
1784
|
+
});
|
|
1785
|
+
record(5, `${label}: every verdict cites a scorer id`, unpinned.length === 0, unpinned.length === 0
|
|
1786
|
+
? ""
|
|
1787
|
+
: `${unpinned.length} verdict spans carry an empty scorerId; a score ` +
|
|
1788
|
+
`without an identity is a number, not evidence`);
|
|
1789
|
+
const unversioned = verdicts.filter((span) => {
|
|
1790
|
+
const version = promoted(span, "scorerVersion", "scorer_version");
|
|
1791
|
+
return typeof version !== "number" || version < 1;
|
|
1792
|
+
});
|
|
1793
|
+
record(5, `${label}: every verdict cites a scorer version`, unversioned.length === 0, unversioned.length === 0
|
|
1794
|
+
? ""
|
|
1795
|
+
: `${unversioned.length} verdict spans carry no usable scorerVersion`);
|
|
1796
|
+
const byId = new Set();
|
|
1797
|
+
for (const span of spans)
|
|
1798
|
+
byId.add(field(span, "spanId"));
|
|
1799
|
+
const roots = spans.filter((span) => {
|
|
1800
|
+
const parent = field(span, "parentSpanId");
|
|
1801
|
+
return parent === undefined || parent === null || parent === "";
|
|
1802
|
+
});
|
|
1803
|
+
record(6, `${label} has exactly one root span`, roots.length === 1, `${roots.length} spans have no parent`);
|
|
1804
|
+
const orphans = spans.filter((span) => {
|
|
1805
|
+
const parent = field(span, "parentSpanId");
|
|
1806
|
+
return typeof parent === "string" && parent.length > 0 && !byId.has(parent);
|
|
1807
|
+
});
|
|
1808
|
+
record(6, `${label} has no orphaned spans`, orphans.length === 0, orphans.length === 0
|
|
1809
|
+
? ""
|
|
1810
|
+
: `${orphans.length} spans name a parent that is not in this trace`);
|
|
1811
|
+
record(6, `${label} is nested, not flat`, spans.length > 1 && roots.length === 1, `${spans.length} spans, ${roots.length} roots`);
|
|
915
1812
|
}
|
|
1813
|
+
// Gate 7 -------------------------------------------------------------------
|
|
1814
|
+
record(7, "inputs and outputs follow the approved content policy", undefined, "re-run with traceContent=false and confirm the span payloads are gone; " +
|
|
1815
|
+
"no read-back can decide this for you");
|
|
1816
|
+
const failures = checks.filter((check) => check.ok === false);
|
|
1817
|
+
const manual = checks.filter((check) => check.ok === undefined);
|
|
1818
|
+
return {
|
|
1819
|
+
experimentId,
|
|
1820
|
+
experiment,
|
|
1821
|
+
checks,
|
|
1822
|
+
results,
|
|
1823
|
+
traceIds: rawTraceIds.filter((value) => typeof value === "string"),
|
|
1824
|
+
verifiedTraceIds: verified,
|
|
1825
|
+
failures,
|
|
1826
|
+
manual,
|
|
1827
|
+
ok: failures.length === 0,
|
|
1828
|
+
raiseForStatus() {
|
|
1829
|
+
if (failures.length === 0)
|
|
1830
|
+
return;
|
|
1831
|
+
throw new Error(`experiment ${experimentId} failed ${failures.length} of ${checks.length} ` +
|
|
1832
|
+
`evidence checks: ` +
|
|
1833
|
+
failures.map((check) => `${check.name} (${check.detail})`).join("; "));
|
|
1834
|
+
},
|
|
1835
|
+
toString() {
|
|
1836
|
+
const passed = checks.filter((check) => check.ok === true).length;
|
|
1837
|
+
const header = `experiment ${experimentId}: ${passed} passed, ${failures.length} failed, ` +
|
|
1838
|
+
`${manual.length} manual`;
|
|
1839
|
+
const lines = checks.map((check) => {
|
|
1840
|
+
const mark = check.ok === true ? "PASS" : check.ok === false ? "FAIL" : "MANUAL";
|
|
1841
|
+
const first = ` [${mark}] gate ${check.gate}: ${check.name}`;
|
|
1842
|
+
return check.detail ? `${first}\n ${check.detail}` : first;
|
|
1843
|
+
});
|
|
1844
|
+
return [header, ...lines].join("\n");
|
|
1845
|
+
},
|
|
1846
|
+
};
|
|
916
1847
|
}
|