@leostera/evalkit 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -283,10 +283,10 @@ function catalogScorer(rule, at) {
283
283
  }
284
284
  function catalogStep(step, at) {
285
285
  switch (step.kind) {
286
- case "user": return {
286
+ case "user": return typeof step.message === "string" ? {
287
287
  kind: "user",
288
288
  message: step.message
289
- };
289
+ } : { kind: "user" };
290
290
  case "agent": return {
291
291
  kind: "agent",
292
292
  expectation: step.expectation
@@ -316,6 +316,22 @@ function shapeOf(value) {
316
316
  return { type: typeof json };
317
317
  }
318
318
  //#endregion
319
+ //#region ../core/src/config.ts
320
+ /** Left out of candidate snapshots unless `artifacts.exclude` says otherwise. */
321
+ const DEFAULT_ARTIFACT_EXCLUDE = [
322
+ ".git",
323
+ "node_modules",
324
+ ".venv",
325
+ "__pycache__",
326
+ "_build",
327
+ "_opam",
328
+ "target"
329
+ ];
330
+ /** Pure, typed project configuration. Loading/discovery belongs to the CLI. */
331
+ function defineConfig(configuration) {
332
+ return configuration;
333
+ }
334
+ //#endregion
319
335
  //#region ../core/src/canonical-json.ts
320
336
  /** Canonical JSON: keys sorted recursively, array order preserved. Rejects non-JSON inputs. */
321
337
  function canonicalParameters(value) {
@@ -603,6 +619,8 @@ const TrialMetadataSchema = Schema.Struct({
603
619
  matrix: Schema.optional(MatrixCellSchema),
604
620
  aut: Schema.optional(AutIdentitySchema),
605
621
  startedAt: Schema.String,
622
+ /** The effective snapshot ignore patterns: the project's, then the scenario's. */
623
+ workspaceIgnore: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
606
624
  datasets: Schema.optional(Schema.mutable(Schema.Array(Schema.Struct({
607
625
  id: Schema.String,
608
626
  visibility: Schema.Literal("candidate", "evaluator"),
@@ -663,8 +681,17 @@ const CheckpointResultSchema = Schema.Struct({
663
681
  id: Schema.String
664
682
  }))
665
683
  });
684
+ const MetricResultSchema = Schema.Struct({
685
+ name: Schema.String,
686
+ value: Schema.optional(Schema.Number),
687
+ unit: Schema.optional(Schema.String),
688
+ better: Schema.optional(Schema.Literal("lower", "higher")),
689
+ builtIn: Schema.optional(Schema.Literal(true)),
690
+ error: Schema.optional(RecordedErrorSchema)
691
+ });
666
692
  const TrialScoringSchema = Schema.Struct({
667
693
  results: Schema.mutable(Schema.Array(ScoreResultSchema)),
694
+ metrics: Schema.optional(Schema.mutable(Schema.Array(MetricResultSchema))),
668
695
  checkpoints: Schema.optional(Schema.mutable(Schema.Array(CheckpointResultSchema))),
669
696
  skippedScorers: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
670
697
  overall: Schema.optional(Schema.Number),
@@ -874,6 +901,10 @@ function expectToolCall(expected) {
874
901
  expected
875
902
  };
876
903
  }
904
+ /**
905
+ * A user message. Pass a function to compute it for each trial, e.g. from a
906
+ * matrix axis: `user(({ parameters }) => `Set up OCaml with ${parameters.toolchain}.`)`.
907
+ */
877
908
  function user(message) {
878
909
  return {
879
910
  kind: "user",
@@ -907,6 +938,15 @@ function predicate(name, run, options = {}) {
907
938
  ...options
908
939
  });
909
940
  }
941
+ function metric(name, run, options = {}) {
942
+ if (!name.trim()) throw new Error("Metric name must not be empty");
943
+ return markDefinitionSite({
944
+ kind: "metric",
945
+ name,
946
+ run,
947
+ ...options
948
+ });
949
+ }
910
950
  function judge(name, options) {
911
951
  if (!name.trim() || !options.rubric.trim()) throw new Error("Judge name and rubric must not be empty");
912
952
  return markDefinitionSite({
@@ -919,12 +959,19 @@ function defineEval(definition) {
919
959
  authoringId(definition);
920
960
  if ("fixtures" in definition) throw new Error(`Eval ${definition.id} uses fixtures, which were replaced by datasets: [defineDataset(...)]`);
921
961
  if (definition.metadata !== void 0) toJsonObject(definition.metadata, `${definition.id}.metadata`);
962
+ for (const [index, pattern] of (definition.workspace?.ignore ?? []).entries()) if (typeof pattern !== "string" || pattern.replace(/^!/, "").trim() === "") throw new Error(`Eval ${definition.id} workspace.ignore[${index}] must be a non-empty glob, got ${JSON.stringify(pattern)}`);
922
963
  const datasetIds = /* @__PURE__ */ new Set();
923
964
  for (const [index, dataset] of (definition.datasets ?? []).entries()) {
924
965
  if (!isDataset(dataset)) throw new Error(`Eval ${definition.id} datasets[${index}] is not a dataset; create it with defineDataset(...)`);
925
966
  if (datasetIds.has(dataset.id)) throw new Error(`Eval ${definition.id} uses dataset ${dataset.id} more than once`);
926
967
  datasetIds.add(dataset.id);
927
968
  }
969
+ const metricNames = new Set(BUILT_IN_METRICS);
970
+ for (const [index, entry] of (definition.metrics ?? []).entries()) {
971
+ if (entry?.kind !== "metric") throw new Error(`Eval ${definition.id} metrics[${index}] is not a metric; create it with metric(...)`);
972
+ if (metricNames.has(entry.name)) throw new Error(`Eval ${definition.id} has more than one metric named ${entry.name} (built-in: ${BUILT_IN_METRICS.join(", ")})`);
973
+ metricNames.add(entry.name);
974
+ }
928
975
  let hasUser = false;
929
976
  for (const step of definition.transcript) if (step.kind === "user") hasUser = true;
930
977
  else if (step.kind !== "agent" && !hasUser) throw new Error("Checkpoint requires a preceding user step");
@@ -1001,6 +1048,12 @@ function registerEvals(registrations) {
1001
1048
  catalog: (options) => evals.map((evaluation) => catalogEntry(evaluation, suiteIdByEvalId.get(authoringId(evaluation)), options))
1002
1049
  };
1003
1050
  }
1051
+ /** Metrics EvalKit records for every trial; scenario metrics may not reuse these names. */
1052
+ const BUILT_IN_METRICS = [
1053
+ "agent time",
1054
+ "tokens",
1055
+ "tool calls"
1056
+ ];
1004
1057
  function recordError(error) {
1005
1058
  if (error instanceof Error) return {
1006
1059
  name: error.name,
@@ -1013,4 +1066,4 @@ function recordError(error) {
1013
1066
  };
1014
1067
  }
1015
1068
  //#endregion
1016
- export { resourceUriSchema as $, TrialManifestSchema as A, NotJsonError as B, RunManifestSchema as C, RunSummarySchema as D, RunStatusSchema as E, normalizeAutEvent as F, relativeDatasetPath as G, toJsonObject as H, turnResultEvents as I, ResourceUriSchema as J, definitionSite as K, defineEvalMatrix as L, TrialScoringSchema as M, TrialSummarySchema as N, ScoreResultSchema as O, UsageSchema as P, resourceUri as Q, canonicalParameters as R, RecordedErrorSchema as S, RunSourceSchema as T, defineDataset as U, toJson as V, isDataset as W, authorId as X, UuidSchema as Y, parseResourceUri as Z, ArtifactEntrySchema as _, defineAgent as a, JsonValueSchema as b, expectToolCall as c, recordError as d, resourceUuid as et, registerEvals as f, ApiErrorSchema as g, parseTrajectoryJsonl as h, authoringId as i, TrialMetadataSchema as j, TrajectoryEventSchema as k, judge as l, user as m, agent as n, defineEval as o, skip as p, ResourceKindSchema as q, agentRuntimes as r, defineSuite as s, DEFAULT_AGENT_RUNTIMES as t, predicate as u, AutIdentitySchema as v, RunMetadataSchema as w, JudgeRunInfoSchema as x, CheckpointResultSchema as y, catalogEntry as z };
1069
+ export { ResourceUriSchema as $, RunSummarySchema as A, defineEvalMatrix as B, JudgeRunInfoSchema as C, RunMetadataSchema as D, RunManifestSchema as E, TrialScoringSchema as F, NotJsonError as G, DEFAULT_ARTIFACT_EXCLUDE as H, TrialSummarySchema as I, defineDataset as J, toJson as K, UsageSchema as L, TrajectoryEventSchema as M, TrialManifestSchema as N, RunSourceSchema as O, TrialMetadataSchema as P, ResourceKindSchema as Q, normalizeAutEvent as R, JsonValueSchema as S, RecordedErrorSchema as T, defineConfig as U, canonicalParameters as V, catalogEntry as W, relativeDatasetPath as X, isDataset as Y, definitionSite as Z, parseTrajectoryJsonl as _, authoringId as a, resourceUuid as at, AutIdentitySchema as b, defineSuite as c, metric as d, UuidSchema as et, predicate as f, user as g, skip as h, agentRuntimes as i, resourceUriSchema as it, ScoreResultSchema as j, RunStatusSchema as k, expectToolCall as l, registerEvals as m, DEFAULT_AGENT_RUNTIMES as n, parseResourceUri as nt, defineAgent as o, recordError as p, toJsonObject as q, agent as r, resourceUri as rt, defineEval as s, BUILT_IN_METRICS as t, authorId as tt, judge as u, ApiErrorSchema as v, MetricResultSchema as w, CheckpointResultSchema as x, ArtifactEntrySchema as y, turnResultEvents as z };
@@ -1,4 +1,4 @@
1
- import { A as TrialManifestSchema, C as RunManifestSchema, D as RunSummarySchema, F as normalizeAutEvent, G as relativeDatasetPath, H as toJsonObject, I as turnResultEvents, M as TrialScoringSchema, N as TrialSummarySchema, O as ScoreResultSchema, Q as resourceUri, R as canonicalParameters, V as toJson, b as JsonValueSchema, d as recordError, h as parseTrajectoryJsonl, i as authoringId, j as TrialMetadataSchema, k as TrajectoryEventSchema, r as agentRuntimes, w as RunMetadataSchema, y as CheckpointResultSchema } from "./src-CZPufWN_.mjs";
1
+ import { A as RunSummarySchema, D as RunMetadataSchema, E as RunManifestSchema, F as TrialScoringSchema, H as DEFAULT_ARTIFACT_EXCLUDE, I as TrialSummarySchema, K as toJson, M as TrajectoryEventSchema, N as TrialManifestSchema, P as TrialMetadataSchema, R as normalizeAutEvent, S as JsonValueSchema, V as canonicalParameters, X as relativeDatasetPath, _ as parseTrajectoryJsonl, a as authoringId, i as agentRuntimes, j as ScoreResultSchema, p as recordError, q as toJsonObject, rt as resourceUri, x as CheckpointResultSchema, z as turnResultEvents } from "./src-LeGSyDD2.mjs";
2
2
  import * as Schema from "effect/Schema";
3
3
  import { Data, Effect, Either, Exit, Scope } from "effect";
4
4
  import os from "node:os";
@@ -32,30 +32,113 @@ async function unlessAborted(work, signal) {
32
32
  }
33
33
  }
34
34
  //#endregion
35
+ //#region ../core/src/glob.ts
36
+ /**
37
+ * A glob matcher for discovery: `*` and `?` stay within a path segment, `**`
38
+ * spans segments, and `{a,b}` is an alternation.
39
+ */
40
+ function globMatcher(pattern) {
41
+ let source = "";
42
+ for (let index = 0; index < pattern.length; index++) {
43
+ const char = pattern[index];
44
+ if (char === "*" && pattern[index + 1] === "*") {
45
+ index++;
46
+ if (pattern[index + 1] === "/") {
47
+ index++;
48
+ source += "(?:.*/)?";
49
+ } else source += ".*";
50
+ } else if (char === "*") source += "[^/]*";
51
+ else if (char === "?") source += "[^/]";
52
+ else if (char === "{") {
53
+ const end = pattern.indexOf("}", index);
54
+ if (end === -1) source += "\\{";
55
+ else {
56
+ source += `(?:${pattern.slice(index + 1, end).split(",").map((part) => part.replace(/[.+^$()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*")).join("|")})`;
57
+ index = end;
58
+ }
59
+ } else source += char.replace(/[.+^$()|[\]\\]/g, "\\$&");
60
+ }
61
+ const expression = new RegExp(`^${source}$`);
62
+ return (path) => expression.test(path);
63
+ }
64
+ //#endregion
35
65
  //#region ../runner/src/snapshot.ts
66
+ const GLOB_CHARS = /[*?{]/;
67
+ function ignoreRule(pattern) {
68
+ const negated = pattern.startsWith("!");
69
+ const trimmed = (negated ? pattern.slice(1) : pattern).replace(/^\.\//, "").replace(/\/$/, "");
70
+ const match = globMatcher(trimmed);
71
+ if (!trimmed.includes("/")) return {
72
+ negated,
73
+ match: (relativePath) => match(path.posix.basename(relativePath)),
74
+ mayMatchBelow: () => true
75
+ };
76
+ const segments = trimmed.split("/");
77
+ const firstGlob = segments.findIndex((segment) => GLOB_CHARS.test(segment));
78
+ const prefix = segments.slice(0, firstGlob === -1 ? segments.length : firstGlob).join("/");
79
+ return {
80
+ negated,
81
+ match,
82
+ mayMatchBelow: (directory) => prefix === "" || prefix.startsWith(`${directory}/`) || prefix === directory || directory.startsWith(`${prefix}/`)
83
+ };
84
+ }
36
85
  /**
37
- * Copies every regular file in the candidate workspace into the trial report.
38
- * Symbolic links are rejected so snapshots cannot follow paths outside the trial.
86
+ * Ignore rules for candidate snapshots, read like `.gitignore`: globs relative
87
+ * to the workspace root, where a pattern without `/` matches that name at any
88
+ * depth, and later patterns win. `!pattern` re-includes a path; unlike Git, it
89
+ * also works below an ignored directory (`_build` then `!_build/doc/**`).
39
90
  */
40
- async function snapshotCandidateWorkspace(candidateRoot, writer) {
91
+ function ignoreRules(patterns) {
92
+ const rules = patterns.map(ignoreRule);
93
+ return {
94
+ /** Whether a path is ignored, given whether its directory was. */
95
+ ignored(relativePath, parentIgnored = false) {
96
+ let ignored = parentIgnored;
97
+ for (const rule of rules) if (rule.match(relativePath)) ignored = !rule.negated;
98
+ return ignored;
99
+ },
100
+ /** Whether an ignored directory must still be walked for re-included paths. */
101
+ mayReinclude(directory) {
102
+ return rules.some((rule) => rule.negated && rule.mayMatchBelow(directory));
103
+ }
104
+ };
105
+ }
106
+ /**
107
+ * Copies the candidate workspace's regular files into the trial report,
108
+ * leaving out ignored paths. Symbolic links and special files are skipped,
109
+ * never followed, so a snapshot cannot read outside the trial. An ignored
110
+ * directory is walked only when a `!` pattern could re-include something in
111
+ * it, and is listed only if it ends up containing a snapshotted file.
112
+ */
113
+ async function snapshotCandidateWorkspace(candidateRoot, writer, ignore = DEFAULT_ARTIFACT_EXCLUDE) {
41
114
  const artifacts = [];
42
- async function visit(directory, relativeDirectory) {
115
+ const rules = ignoreRules(ignore);
116
+ /** Returns whether anything below `directory` was snapshotted. */
117
+ async function visit(directory, relativeDirectory, directoryIgnored) {
43
118
  const entries = await readdir(directory, { withFileTypes: true });
44
119
  entries.sort((left, right) => left.name.localeCompare(right.name));
120
+ let kept = false;
45
121
  for (const entry of entries) {
46
122
  const absolutePath = path.join(directory, entry.name);
47
123
  const relativePath = path.join(relativeDirectory, entry.name);
124
+ const posixPath = relativePath.split(path.sep).join("/");
125
+ const ignored = rules.ignored(posixPath, directoryIgnored);
48
126
  const stat = await lstat(absolutePath);
49
- if (stat.isSymbolicLink()) throw new Error(`Candidate workspace contains a symbolic link: ${relativePath}`);
127
+ if (stat.isSymbolicLink()) continue;
50
128
  if (stat.isDirectory()) {
51
- artifacts.push({
52
- path: path.join("candidate", relativePath),
53
- kind: "directory"
54
- });
55
- await visit(absolutePath, relativePath);
129
+ if (ignored && !rules.mayReinclude(posixPath)) continue;
130
+ const entryIndex = artifacts.length;
131
+ const keptBelow = await visit(absolutePath, relativePath, ignored);
132
+ if (!ignored || keptBelow) {
133
+ artifacts.splice(entryIndex, 0, {
134
+ path: path.join("candidate", relativePath),
135
+ kind: "directory"
136
+ });
137
+ kept = true;
138
+ }
56
139
  continue;
57
140
  }
58
- if (!stat.isFile()) throw new Error(`Candidate workspace contains an unsupported entry: ${relativePath}`);
141
+ if (ignored || !stat.isFile()) continue;
59
142
  const data = await readFile(absolutePath);
60
143
  const artifactPath = path.join("candidate", relativePath);
61
144
  await writer.writeArtifact(artifactPath, data);
@@ -64,9 +147,11 @@ async function snapshotCandidateWorkspace(candidateRoot, writer) {
64
147
  kind: "file",
65
148
  size: data.byteLength
66
149
  });
150
+ kept = true;
67
151
  }
152
+ return kept;
68
153
  }
69
- await visit(candidateRoot, "");
154
+ await visit(candidateRoot, "", false);
70
155
  return artifacts;
71
156
  }
72
157
  //#endregion
@@ -247,8 +332,9 @@ async function staticDatasetFile(dataset, root, file) {
247
332
  }
248
333
  //#endregion
249
334
  //#region ../runner/src/workspace.ts
250
- function trialDirectoryName(trialId) {
251
- return trialId.replace(/[^a-zA-Z0-9._-]/g, "_");
335
+ /** Where a trial's persistent workspace lives below `root`. */
336
+ function trialWorkspacePath(root, trialId) {
337
+ return path.join(path.resolve(root), trialId.replace(/[^a-zA-Z0-9._-]/g, "_"));
252
338
  }
253
339
  /**
254
340
  * Creates isolated candidate and evaluator directories and writes each
@@ -256,7 +342,7 @@ function trialDirectoryName(trialId) {
256
342
  * Two datasets writing the same file is an error, never an overwrite.
257
343
  */
258
344
  async function createTrialWorkspace(trialId, datasets = [], persistentRoot) {
259
- const root = persistentRoot ? path.join(path.resolve(persistentRoot), trialDirectoryName(trialId)) : await mkdtemp(path.join(os.tmpdir(), "evalkit-trial-"));
345
+ const root = persistentRoot ? trialWorkspacePath(persistentRoot, trialId) : await mkdtemp(path.join(os.tmpdir(), "evalkit-trial-"));
260
346
  const persistent = persistentRoot !== void 0;
261
347
  const artifacts = {
262
348
  candidate: { root: path.join(root, "candidate") },
@@ -270,10 +356,13 @@ async function createTrialWorkspace(trialId, datasets = [], persistentRoot) {
270
356
  }
271
357
  return {
272
358
  artifacts,
273
- cleanup: persistent ? async () => {} : () => rm(root, {
274
- recursive: true,
275
- force: true
276
- })
359
+ cleanup: async (keep = false) => {
360
+ if (persistent && keep) return;
361
+ await rm(root, {
362
+ recursive: true,
363
+ force: true
364
+ });
365
+ }
277
366
  };
278
367
  } catch (error) {
279
368
  await rm(root, {
@@ -545,7 +634,7 @@ async function executeTranscript(definition, session, input) {
545
634
  let turn = 0;
546
635
  for (const [index, step] of definition.transcript.entries()) {
547
636
  if (input.signal.aborted) throw cancelled();
548
- const message = step.kind === "user" ? step.message.replaceAll("{{randomSeed}}", String(input.randomSeed)) : void 0;
637
+ const message = step.kind === "user" ? userMessage(step, index, input) : void 0;
549
638
  await emitRunner({
550
639
  kind: "transcript-step-started",
551
640
  step: index,
@@ -586,6 +675,18 @@ async function executeTranscript(definition, session, input) {
586
675
  });
587
676
  }
588
677
  }
678
+ /** A computed message sees the parameters and candidate data the agent also sees. */
679
+ function userMessage(step, index, { context, randomSeed }) {
680
+ if (typeof step.message === "string") return step.message;
681
+ const message = step.message({
682
+ parameters: context.parameters ?? {},
683
+ data: context.data,
684
+ seed: randomSeed,
685
+ trialIndex: context.trialIndex
686
+ });
687
+ if (typeof message !== "string" || !message.trim()) throw new Error(`The user message at step ${index + 1} must compute a non-empty string`);
688
+ return message;
689
+ }
589
690
  async function sendUserTurn(session, message, signal) {
590
691
  return runBoundary(Effect.tryPromise({
591
692
  try: () => unlessAborted(() => session.send(message), signal),
@@ -611,7 +712,8 @@ async function runCheckpoint(definition, step, index, input) {
611
712
  trajectory: { events: input.events },
612
713
  turn: progress.lastTurn,
613
714
  ...turnOutput(progress.lastTurn),
614
- data: input.data
715
+ data: input.data,
716
+ checkpoints: [...progress.checkpoints]
615
717
  }, now, definition.judge)).catch(async (error) => {
616
718
  const recorded = recordError(error);
617
719
  progress.checkpoints.push({
@@ -656,20 +758,33 @@ function turnOutput(turn) {
656
758
  }
657
759
  //#endregion
658
760
  //#region ../runner/src/final-scoring.ts
659
- function scoreSummary(results, checkpoints, skippedScorers) {
761
+ function scoreSummary(results, checkpoints, skippedScorers, metrics = []) {
660
762
  const valid = results.filter((result) => result.value !== void 0 && !result.skipped);
661
763
  const hasError = results.some((result) => result.error !== void 0);
662
764
  return {
663
765
  results,
766
+ ...metrics.length ? { metrics } : {},
664
767
  ...checkpoints.length ? { checkpoints } : {},
665
768
  ...skippedScorers.length ? { skippedScorers } : {},
666
769
  ...valid.length === 0 ? {} : { overall: valid.reduce((sum, result) => sum + (result.value ?? 0), 0) / valid.length },
667
770
  passed: !hasError && results.every((result) => result.skipped || result.passed === true) && checkpoints.every((result) => result.status === "passed" || result.status === "skipped") && !skippedScorers.length
668
771
  };
669
772
  }
773
+ /** What final scorers and metrics receive. */
774
+ function scoringContext(input) {
775
+ return {
776
+ context: input.context,
777
+ artifacts: input.workspace.artifacts,
778
+ trajectory: { events: input.events },
779
+ ...input.lastTurn ? { turn: input.lastTurn } : {},
780
+ ...turnOutput(input.lastTurn),
781
+ data: input.data,
782
+ checkpoints: input.checkpoints
783
+ };
784
+ }
670
785
  /** Append completed scores to the supplied arrays, even when a later scorer or report write fails. */
671
786
  async function evaluateFinalScorers(definition, input) {
672
- const { context, workspace, events, lastTurn, results, skippedScorers, now, emitRunner } = input;
787
+ const { context, workspace, results, skippedScorers, now, emitRunner } = input;
673
788
  for (const scorer of definition.scoring) {
674
789
  if (input.cancelled || input.reportingFailed || !context || !workspace || (input.primaryError || input.stoppedEarly) && (!("supportsPartial" in scorer) || !scorer.supportsPartial)) {
675
790
  skippedScorers.push(scorer.name);
@@ -683,14 +798,11 @@ async function evaluateFinalScorers(definition, input) {
683
798
  });
684
799
  let result;
685
800
  try {
686
- const score = await runBoundary(evaluateRule(scorer, {
801
+ const score = await runBoundary(evaluateRule(scorer, scoringContext({
802
+ ...input,
687
803
  context,
688
- artifacts: workspace.artifacts,
689
- trajectory: { events },
690
- ...lastTurn ? { turn: lastTurn } : {},
691
- ...turnOutput(lastTurn),
692
- data: input.data
693
- }, definition.judge, "scoring").pipe(Effect.mapError((cause) => new ScoringError({
804
+ workspace
805
+ }), definition.judge, "scoring").pipe(Effect.mapError((cause) => new ScoringError({
694
806
  cause,
695
807
  message: cause.message
696
808
  }))));
@@ -727,6 +839,68 @@ async function evaluateFinalScorers(definition, input) {
727
839
  }
728
840
  }
729
841
  //#endregion
842
+ //#region ../runner/src/metrics.ts
843
+ /**
844
+ * What EvalKit measures for every trial from its trajectory: the agent's time
845
+ * (first event to the last one before final scoring), tokens when the agent
846
+ * reported usage, and tool calls.
847
+ */
848
+ function builtInMetrics(events) {
849
+ const times = events.filter((event) => !event.kind.startsWith("scorer-")).map((event) => Date.parse(event.timestamp)).filter((time) => !Number.isNaN(time));
850
+ const metrics = [];
851
+ if (times.length) metrics.push({
852
+ name: "agent time",
853
+ value: Math.max(...times) - Math.min(...times),
854
+ unit: "ms",
855
+ better: "lower",
856
+ builtIn: true
857
+ });
858
+ let tokens;
859
+ for (const event of events) if (event.source === "aut" && event.kind === "turn-completed" && event.usage) {
860
+ const { totalTokens, inputTokens, outputTokens } = event.usage;
861
+ const turn = totalTokens ?? (inputTokens === void 0 && outputTokens === void 0 ? void 0 : (inputTokens ?? 0) + (outputTokens ?? 0));
862
+ if (turn !== void 0) tokens = (tokens ?? 0) + turn;
863
+ }
864
+ if (tokens !== void 0) metrics.push({
865
+ name: "tokens",
866
+ value: tokens,
867
+ unit: "tokens",
868
+ better: "lower",
869
+ builtIn: true
870
+ });
871
+ metrics.push({
872
+ name: "tool calls",
873
+ value: events.filter((event) => event.source === "aut" && event.kind === "tool-call").length,
874
+ builtIn: true
875
+ });
876
+ return metrics;
877
+ }
878
+ /** Runs the scenario's metrics; a failing metric records its error and the rest still run. */
879
+ async function evaluateMetrics(definition, context) {
880
+ const results = [];
881
+ for (const entry of definition.metrics ?? []) {
882
+ const shape = {
883
+ name: entry.name,
884
+ ...entry.unit ? { unit: entry.unit } : {},
885
+ ...entry.better ? { better: entry.better } : {}
886
+ };
887
+ try {
888
+ const value = await entry.run(context);
889
+ if (value !== void 0 && !Number.isFinite(value)) throw new Error(`Metric ${entry.name} must return a finite number or undefined; got ${String(value)}`);
890
+ results.push({
891
+ ...shape,
892
+ ...value === void 0 ? {} : { value }
893
+ });
894
+ } catch (error) {
895
+ results.push({
896
+ ...shape,
897
+ error: recordError(error)
898
+ });
899
+ }
900
+ }
901
+ return results;
902
+ }
903
+ //#endregion
730
904
  //#region ../runner/src/local-report-store.ts
731
905
  const MANIFEST_FILE = "manifest.json";
732
906
  /** The project's evals and suites when the run started (schema version 4+). */
@@ -1071,6 +1245,7 @@ async function runTrial(definition, options) {
1071
1245
  }
1072
1246
  const candidateData = datasetValues(prepared, "candidate");
1073
1247
  const scoringData = datasetValues(prepared, "all");
1248
+ const workspaceIgnore = [...options.artifactExclude ?? DEFAULT_ARTIFACT_EXCLUDE, ...definition.workspace?.ignore ?? []];
1074
1249
  const events = [];
1075
1250
  const runWriter = options.runWriter ?? await options.report.startRun(runMetadata(definition, options, runId, startedAt), { catalog: options.catalog });
1076
1251
  const trialWriter = await runWriter.startTrial({
@@ -1085,6 +1260,7 @@ async function runTrial(definition, options) {
1085
1260
  parameters: options.parameters,
1086
1261
  matrix: options.matrix,
1087
1262
  startedAt,
1263
+ workspaceIgnore,
1088
1264
  ...prepared.length ? { datasets: trialDatasets(prepared) } : {}
1089
1265
  });
1090
1266
  let writeChain = Promise.resolve();
@@ -1155,7 +1331,7 @@ async function runTrial(definition, options) {
1155
1331
  message: cause instanceof Error ? cause.message : String(cause)
1156
1332
  })
1157
1333
  }), (created) => Effect.tryPromise({
1158
- try: () => created.cleanup(),
1334
+ try: () => created.cleanup(keepWorkspace(options.keepWorkspaces, scoring?.passed === true)),
1159
1335
  catch: (cause) => new DatasetError({
1160
1336
  cause,
1161
1337
  message: cause instanceof Error ? cause.message : String(cause)
@@ -1220,6 +1396,7 @@ async function runTrial(definition, options) {
1220
1396
  const { checkpoints, stoppedEarly, lastTurn } = progress;
1221
1397
  const scoreResults = [];
1222
1398
  const skippedScorers = [];
1399
+ const metrics = [];
1223
1400
  try {
1224
1401
  await evaluateFinalScorers(definition, {
1225
1402
  context,
@@ -1233,13 +1410,23 @@ async function runTrial(definition, options) {
1233
1410
  results: scoreResults,
1234
1411
  skippedScorers,
1235
1412
  data: scoringData,
1413
+ checkpoints,
1236
1414
  now,
1237
1415
  emitRunner
1238
1416
  });
1239
- scoring = scoreSummary(scoreResults, checkpoints, skippedScorers);
1417
+ metrics.push(...builtInMetrics(events));
1418
+ if (context && workspace && !wasCancelled) metrics.push(...await evaluateMetrics(definition, scoringContext({
1419
+ context,
1420
+ workspace,
1421
+ events,
1422
+ lastTurn,
1423
+ data: scoringData,
1424
+ checkpoints
1425
+ })));
1426
+ scoring = scoreSummary(scoreResults, checkpoints, skippedScorers, metrics);
1240
1427
  await trialWriter.writeScores(scoring);
1241
1428
  if (workspace) try {
1242
- artifacts = await snapshotCandidateWorkspace(workspace.artifacts.candidate.root, trialWriter);
1429
+ artifacts = await snapshotCandidateWorkspace(workspace.artifacts.candidate.root, trialWriter, workspaceIgnore);
1243
1430
  } catch (captureError) {
1244
1431
  if (!primaryError) primaryError = captureError;
1245
1432
  await tryEmitError(captureError, emitRunner, now);
@@ -1254,7 +1441,7 @@ async function runTrial(definition, options) {
1254
1441
  await tryEmitError(cleanupError, emitRunner, now);
1255
1442
  }
1256
1443
  }
1257
- scoring ??= scoreSummary(scoreResults, checkpoints, skippedScorers);
1444
+ scoring ??= scoreSummary(scoreResults, checkpoints, skippedScorers, metrics);
1258
1445
  if (!primaryError) try {
1259
1446
  await emitRunner({
1260
1447
  kind: "trial-completed",
@@ -1327,5 +1514,9 @@ function trialCounts(trials) {
1327
1514
  cancelled: cancelledTrials
1328
1515
  };
1329
1516
  }
1517
+ /** Persistent workspaces of trials that did not pass are kept for debugging by default. */
1518
+ function keepWorkspace(policy = "failed", passed) {
1519
+ return policy === "all" || policy === "failed" && !passed;
1520
+ }
1330
1521
  //#endregion
1331
- export { ReportError as _, readRunSummary as a, readTrialScoring as c, staticDatasetFile as d, staticDatasetFiles as f, DatasetError as g, CheckpointExecutionError as h, readRunManifest as i, readTrialSummary as l, CancelledError as m, runMatrix as n, readTrialEvents as o, AutExecutionError as p, ownerIsGone as r, readTrialManifest as s, runEval as t, localReportStore as u, ScoringError as v };
1522
+ export { CheckpointExecutionError as _, readRunSummary as a, ScoringError as b, readTrialScoring as c, trialWorkspacePath as d, staticDatasetFile as f, CancelledError as g, AutExecutionError as h, readRunManifest as i, readTrialSummary as l, globMatcher as m, runMatrix as n, readTrialEvents as o, staticDatasetFiles as p, ownerIsGone as r, readTrialManifest as s, runEval as t, localReportStore as u, DatasetError as v, ReportError as y };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leostera/evalkit",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
4
4
  "description": "Author and run agent evals as code",
5
5
  "type": "module",
6
6
  "license": "MIT",