@ls-stack/agent-eval 0.14.0 → 0.16.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.
@@ -0,0 +1,408 @@
1
+ ---
2
+ name: agent-eval
3
+ description: Create, run, and maintain TypeScript evals with @ls-stack/agent-eval. Use when adding eval coverage for an LLM or agent workflow, updating *.eval.ts files, checking eval results, configuring agent-evals.config.ts, inspecting saved .agent-evals run artifacts, or wiring product source code with evalTracer spans.
4
+ ---
5
+
6
+ # Agent Eval
7
+
8
+ Local-first, UI-first eval runner for LLM and agent systems. Evals are strict
9
+ TypeScript modules named `*.eval.ts`, discovered from `agent-evals.config.ts`,
10
+ and executed through the CLI (`agent-evals run`) or the web UI
11
+ (`agent-evals app`). Runs persist to `.agent-evals/` so results, traces, and
12
+ caches survive across processes.
13
+
14
+ This skill covers the mental model and conventions. For exhaustive field lists
15
+ (config options, eval shape, column formats, score/chart/stats options, trace
16
+ display rules), read the TypeScript declarations shipped with the package:
17
+
18
+ - `AgentEvalsConfig`, `EvalDefinition`, `EvalCase`, `EvalOutputs`,
19
+ `EvalColumnOverride`, `EvalScoreDef`, `EvalManualScoreDef`,
20
+ `EvalTraceTree`, `TraceSpanInfo`, and `z` are exported from
21
+ `@ls-stack/agent-eval`.
22
+ - `.d.ts` files land in `node_modules/@ls-stack/agent-eval/dist/`.
23
+ - CLI surface: `agent-evals --help` and `agent-evals <command> --help`.
24
+ Unknown help targets exit non-zero instead of falling back to global help.
25
+ - The CLI automatically loads `.env` from the current workspace. Shell-provided
26
+ environment variables win; pass `--no-env` to disable `.env` loading once.
27
+ - Unfiltered `agent-evals run` is disabled by default; use `--eval` or `--case`
28
+ for targeted CLI runs. Set `allowCliRunAll: true` in
29
+ `agent-evals.config.ts` to opt into run-all CLI behavior. The web UI can
30
+ still run grouped evals and confirms before starting more than five.
31
+
32
+ Assume that enumerated tables in this document may lag behind the types —
33
+ treat the types as source of truth when they disagree.
34
+
35
+ ## Where tracing lives
36
+
37
+ **Tracing belongs in the product source code, not in the eval file.** The eval
38
+ file wires up cases and scoring; the real `evalTracer.span(...)` calls sit
39
+ inside the workflow, agent, or tool functions that both production and evals
40
+ invoke.
41
+
42
+ `evalTracer`, `evalSpan`, output helpers, and `evalAssert` are ambient no-ops
43
+ when called outside an eval case scope, so leaving them in production paths is
44
+ safe — they only record anything when the product code runs inside an eval's
45
+ `execute`. Use `isInEvalScope()` to branch on eval-only behavior in shared code
46
+ (e.g. skip a real network side effect): it returns `null` outside eval-owned
47
+ work and returns `'env'`, `'cases'`, `'eval'`, `'derive'`, `'outputsSchema'`, or
48
+ `'scorer'` during runner phases. Top-level modules imported while a run is being
49
+ prepared see `'env'`; code called from `execute` sees `'eval'`. Use
50
+ `getEvalCaseInput()` to read the current case input, or
51
+ `getEvalCaseInput('customer.tier')` for nested dot-path access; outside a case
52
+ scope it returns `undefined`. Use `nextEvalId()` inside eval-scoped code when a
53
+ stable generated id is needed; it includes the eval file, eval id, case id, and
54
+ a per-case sequence number, and throws outside an eval case scope.
55
+
56
+ ### Product code (instrumented once, reused everywhere)
57
+
58
+ ```ts
59
+ // src/workflows/refundWorkflow.ts
60
+ import {
61
+ appendToEvalOutput,
62
+ captureEvalSpanError,
63
+ evalAssert,
64
+ evalSpan,
65
+ evalTracer,
66
+ getEvalCaseInput,
67
+ incrementEvalOutput,
68
+ mergeEvalOutput,
69
+ nextEvalId,
70
+ setEvalOutput,
71
+ startEvalBackgroundJob,
72
+ } from '@ls-stack/agent-eval';
73
+
74
+ export async function runRefundWorkflow(input: RefundInput) {
75
+ return evalTracer.span(
76
+ { kind: 'agent', name: 'refund-workflow' },
77
+ async () => {
78
+ evalSpan.setAttribute('input', input);
79
+
80
+ const plan = await evalTracer.span(
81
+ {
82
+ kind: 'llm',
83
+ name: 'plan-refund',
84
+ cache: { key: { prompt: input.message, model: 'gpt-4o-mini' } },
85
+ },
86
+ async () => {
87
+ let text: string;
88
+ let usage: { inputTokens: number; outputTokens: number };
89
+ let costUsd: number;
90
+ try {
91
+ ({ text, usage, costUsd } = await llm.complete(input.message));
92
+ } catch (error) {
93
+ captureEvalSpanError(error);
94
+ ({ text, usage, costUsd } = await llm.completeWithFallback(
95
+ input.message,
96
+ ));
97
+ }
98
+ evalSpan.setAttributes({ model: 'gpt-4o-mini', usage });
99
+ const expectedLocale = getEvalCaseInput('locale');
100
+ if (typeof expectedLocale === 'string') {
101
+ evalSpan.setAttribute('expectedLocale', expectedLocale);
102
+ }
103
+ evalSpan.incrementAttribute('llmCalls', 1);
104
+ evalSpan.appendToAttribute('models', 'gpt-4o-mini');
105
+ incrementEvalOutput('costUsd', costUsd);
106
+ appendToEvalOutput('modelCalls', { model: 'gpt-4o-mini', costUsd });
107
+ return text;
108
+ },
109
+ );
110
+
111
+ const result = await applyRefund(plan);
112
+ const reviewId = nextEvalId();
113
+ setEvalOutput('response', result.finalText);
114
+ setEvalOutput('reviewId', reviewId);
115
+ mergeEvalOutput('metadata', { approved: result.approved });
116
+ evalAssert(result.approved, 'refund workflow should approve the case');
117
+ evalSpan.setAttribute('output', { result, reviewId });
118
+ return result;
119
+ },
120
+ );
121
+ }
122
+ ```
123
+
124
+ Span `kind` values are open-ended strings. Use familiar kinds such as
125
+ `agent`, `tool`, `llm`, `api`, `retrieval`, `scorer`, or `checkpoint` when they
126
+ fit, and preserve external tracer kinds such as `mastra.workflow.step` when they
127
+ are more specific. Only the `input` and `output` span attributes are promoted
128
+ automatically; use `traceDisplay` for other span attributes such as `model`,
129
+ `usage`, or `costUsd`.
130
+
131
+ Use `captureEvalSpanError(error)` for recoverable errors on the active
132
+ `evalTracer.span(...)`, such as optional model/tool failures that fall back and
133
+ continue. You can pass one error, multiple error arguments, or an array. The
134
+ span is still marked `error`. Pass `'warning'` or `{ level: 'warning' }` as the
135
+ final argument for diagnostics that should not change an otherwise successful
136
+ span's status.
137
+
138
+ If a span callback throws, the SDK automatically marks that span as `error`,
139
+ stores the thrown error on it, and rethrows so the case errors. Use that for
140
+ terminal failures; use `captureEvalSpanError(...)` for recoverable failures that
141
+ continue through fallback logic.
142
+
143
+ Fire-and-forget spans started during `execute` are awaited before outputs,
144
+ `deriveFromTracing`, scores, and trace data are finalized, so `void
145
+ evalTracer.span(...)` is safe when the span result is not needed. Register
146
+ non-span promises with `startEvalBackgroundJob(promise)`. The runner only waits
147
+ for settlement; promise and span errors keep their normal behavior. Use
148
+ `waitForBackgroundJob: false` on a span, or `waitForBackgroundJobs: false` on an
149
+ eval definition, when background work should not delay finalization.
150
+
151
+ For libraries or observability exporters that already emit span lifecycle
152
+ events, use `evalTracer.startSpan(...)`, `evalTracer.updateSpan(...)`,
153
+ `evalTracer.endSpan(...)`, or `evalTracer.recordSpan(...)` to translate those
154
+ events into the eval trace tree without wrapping the upstream work in a
155
+ callback. Pass the upstream span id and parent id when available so the UI keeps
156
+ the original hierarchy.
157
+
158
+ ### Eval file (thin)
159
+
160
+ ```ts
161
+ // evals/refund-workflow.eval.ts
162
+ import { defineEval, z } from '@ls-stack/agent-eval';
163
+ import { runRefundWorkflow } from '../src/workflows/refundWorkflow.ts';
164
+
165
+ const outputsSchema = z.object({
166
+ response: z.string(),
167
+ costUsd: z.number().optional(),
168
+ toolCalls: z.number(),
169
+ llmTurns: z.number(),
170
+ });
171
+ type RefundOutputs = z.infer<typeof outputsSchema>;
172
+
173
+ defineEval<RefundInput, RefundOutputs>({
174
+ id: 'refund-workflow',
175
+ cases: [
176
+ { id: 'simple-text', input: { message: 'I want a refund for order #123' } },
177
+ ],
178
+ outputsSchema,
179
+ execute: async ({ input }) => {
180
+ await runRefundWorkflow(input);
181
+ },
182
+ deriveFromTracing: ({ trace }) => ({
183
+ toolCalls: trace.findSpansByKind('tool').length,
184
+ llmTurns: trace.findSpansByKind('llm').length,
185
+ }),
186
+ scores: {
187
+ mentionsRefund: {
188
+ passThreshold: 1,
189
+ compute: ({ outputs }) => (/refund/i.test(outputs.response) ? 1 : 0),
190
+ },
191
+ },
192
+ });
193
+ ```
194
+
195
+ `execute` usually just calls the product code. Push any placeholder
196
+ `evalTracer.span(...)` wrappers out of the eval and into the product module
197
+ they describe so production runs get the same trajectory. Only keep tracing
198
+ inside `execute` when the behavior being measured is eval-specific (e.g. a
199
+ judge-only sub-step with no production analogue).
200
+
201
+ Case `id` values anchor historical runs, caches, and manual scores — keep them
202
+ stable. See `EvalDefinition` / `EvalCase` in the types for every supported
203
+ field.
204
+
205
+ ## Scoring
206
+
207
+ Every score returns a normalized `0..1` value. Pass/fail is per-score: a case
208
+ fails if any score with `passThreshold` falls below it, if an assertion fails,
209
+ or if the case errors. Scores without `passThreshold` are informational.
210
+
211
+ Score functions run in their own trace scope, separate from the execution
212
+ trace, so LLM-as-judge scorers can use `evalTracer.span(...)` and cached spans
213
+ without polluting the agent trajectory. Outputs set inside a scorer stay
214
+ private to that score.
215
+
216
+ `manualScores` declares score columns that reviewers fill in after a run.
217
+ Pending values keep the eval in an `unscored` state instead of failing.
218
+
219
+ See `EvalScoreDef` / `EvalManualScoreDef` in the types for the full shape
220
+ (format, threshold, column overrides).
221
+
222
+ ## Outputs, columns, trace display
223
+
224
+ - `setEvalOutput(key, value)` writes reviewable data for the case. Values are
225
+ plain data (strings, numbers, booleans, JSON-safe objects) plus native
226
+ `Blob`/`File` or `FileRef` variants for media columns. Inside `execute`,
227
+ prefer the context `setOutput(key, value)` helper when writing schema-backed
228
+ outputs; it is typed from the eval's outputs generic. Keep `setEvalOutput`
229
+ for shared workflow code that does not receive the execute context.
230
+ - Use `incrementEvalOutput(key, delta)` for numeric totals,
231
+ `appendToEvalOutput(key, value)` for arrays that preserve existing scalar
232
+ values, and `mergeEvalOutput(key, patch)` for shallow object updates.
233
+ `evalSpan` has matching `incrementAttribute`, `appendToAttribute`, and
234
+ `mergeAttribute` helpers for span attributes.
235
+ - `outputsSchema` validates final outputs after `execute` and
236
+ `deriveFromTracing`, before computed scores. For Zod object schemas, only
237
+ declared keys are passed to the schema; parsed fields merge back into the raw
238
+ output map, so defaults/transforms apply to configured fields and
239
+ unconfigured outputs stay visible as before. Validation failures fail the case
240
+ and skip computed scores. When you pass a narrowed outputs type as the second
241
+ `defineEval` generic, `outputsSchema` is required.
242
+ - `columns` overrides the display for output and score keys (label, format,
243
+ alignment, visibility). The set of supported formats is declared by the
244
+ `ColumnFormat` union and `EvalColumnOverride` in the types.
245
+ - `traceDisplay` promotes selected span attributes into the trace tree and
246
+ detail pane; it supports aggregation across subtrees (`scope`, `mode`) and
247
+ user-defined `transform(...)` for derived views (e.g. currency conversion).
248
+ See the `TraceDisplayInputConfig` type.
249
+ - `llmCalls` (in `agent-evals.config.ts`) configures how LLM-call spans are
250
+ summarized for review. Defaults to `kind: 'llm'` spans with `model`,
251
+ `usage.*`, `costUsd`, `input`, `output`, etc. read from conventional
252
+ attribute paths. Override `kinds` to broaden the filter, override
253
+ `attributes.<field>` for non-default span shapes, and add entries to
254
+ `metrics` to surface arbitrary user metrics (`format: 'string' | 'number' |
255
+ 'duration' | 'json' | 'boolean'`, `placements: ['header' | 'body']`).
256
+ - `apiCalls` (in `agent-evals.config.ts`) configures how API-call spans are
257
+ summarized for review. Defaults to `kind: 'api'`, `'http'`, `'http.client'`,
258
+ and `'fetch'` spans with `method`, `url`, `statusCode`, `request`,
259
+ `response`, `requestBody`, `responseBody`, `headers`, `durationMs`, and
260
+ `error` read from conventional attribute paths. Override `kinds` or
261
+ `attributes.<field>` for external tracers, and add `metrics` with the same
262
+ formats and placements as LLM-call metrics.
263
+
264
+ Stats rows and history charts on the eval card are opt-in via `stats` /
265
+ `charts` on the eval definition. Their shapes live in the types; no need to
266
+ memorize the option set.
267
+
268
+ ## Cached operations
269
+
270
+ Wrap a costly pure span in `cache: { key }` so later runs replay its recorded
271
+ effects without re-executing:
272
+
273
+ ```ts
274
+ await evalTracer.span(
275
+ {
276
+ kind: 'llm',
277
+ name: 'plan-refund',
278
+ cache: { key: { prompt: input.message, model: 'gpt-4o-mini' } },
279
+ },
280
+ async () => {
281
+ const result = await llm.complete(input.message);
282
+ evalSpan.setAttributes({ model: 'gpt-4o-mini', output: result });
283
+ incrementEvalOutput('costUsd', computeCost(result));
284
+ appendToEvalOutput('llmCalls', { model: 'gpt-4o-mini' });
285
+ return result;
286
+ },
287
+ );
288
+ ```
289
+
290
+ Use `evalTracer.cache(...)` for pure values that should not create their own
291
+ trace span:
292
+
293
+ ```ts
294
+ const context = await evalTracer.cache(
295
+ { name: 'receipt-audit-context', key: { orderId: input.orderId } },
296
+ async () => {
297
+ const result = await loadReceiptContext(input);
298
+ evalSpan.setAttribute('receiptContext', result);
299
+ evalSpan.mergeAttribute('receiptSummary', { orderId: input.orderId });
300
+ return result;
301
+ },
302
+ );
303
+ ```
304
+
305
+ Mental model:
306
+
307
+ - Only SDK-mediated effects replay on a hit: sub-spans, checkpoints,
308
+ output helper calls, span attributes. External side
309
+ effects (HTTP, DB writes, file I/O) **do not** replay — cache only pure
310
+ functions of the key.
311
+ - `evalTracer.cache(...)` does not create a span. When it runs inside an active
312
+ span, that span gets a `cache.refs` entry with the value cache name, key,
313
+ namespace, and hit/miss status. When called directly from the case body
314
+ (no surrounding span), the ref is recorded on the case detail's `cacheRefs`
315
+ array.
316
+ - The cache key folds in a source-file fingerprint, so editing the eval busts
317
+ the cache automatically.
318
+ - `cache.namespace` on spans or `namespace` on value caches can share entries
319
+ across operations/evals, but the source-file fingerprint still participates
320
+ in the final key. Shared namespaces are reusable across evals in the same
321
+ file; evals in different files miss even with the same namespace and key.
322
+ - Cache keys should be deterministic primitives, arrays, and plain objects.
323
+ `Buffer`, `ArrayBuffer`, and typed arrays hash by bytes. Native `Blob`/`File`
324
+ keys use stable metadata by default (`type`, `size`, plus
325
+ `name`/`lastModified` for `File`) and do not read file bytes. Add
326
+ `serializeFileBytes: true` to a cached span or `evalTracer.cache(...)` call
327
+ when byte-level cache invalidation is required.
328
+ - Cache entries are stored in inspectable owner files under
329
+ `.agent-evals/cache/<owner>.json`; each namespace is capped at 100 entries by
330
+ default. Configure `cache.maxEntriesPerNamespace` for the default cap and
331
+ `cache.maxEntriesByNamespace` for exact namespace-specific caps.
332
+ - Cached payloads use advance serialization/deserialization with the Web API plugin set, so return values and
333
+ recorded SDK effects preserve richer built-ins such as `Date`, `Map`, `Set`,
334
+ typed arrays, `URL`, `Headers`, `Blob`, and `File` on hits. Cache keys still
335
+ use the deterministic key-hashing rules above.
336
+ - Cache mode per run is controlled by CLI flags (see `agent-evals run --help`).
337
+
338
+ ## Artifacts
339
+
340
+ Run output lives under `.agent-evals/runs/<run-id>/` and cache entries under
341
+ `.agent-evals/cache/<eval-id>.json`. Files in a run directory include run
342
+ metadata, a run summary, per-case results, and per-case trace JSON. Inspect
343
+ these when debugging persisted output, costs, columns, traces, or failures.
344
+
345
+ Use `agent-evals show-runs` when you need stable file
346
+ paths before reading saved output:
347
+
348
+ ```sh
349
+ agent-evals show-runs
350
+ agent-evals show-runs latest --json
351
+ jq . .agent-evals/runs/<run-id>/summary.json
352
+ jq -s . .agent-evals/runs/<run-id>/cases.jsonl
353
+ jq . .agent-evals/runs/<run-id>/case-details/<case-id>.json
354
+ jq . .agent-evals/runs/<run-id>/traces/<case-id>.json
355
+ ```
356
+
357
+ Run ids can be full timestamp ids, short ids such as `r0` from
358
+ `agent-evals show-runs`, or `latest`. `show-runs` is only an artifact index;
359
+ the files themselves remain the source of truth for detailed results and
360
+ traces.
361
+
362
+ ## Module mocking
363
+
364
+ For true module replacement inside an eval, register `mock.module(...)` from
365
+ `node:test` before dynamically importing the module graph. The CLI enables
366
+ Node's `--experimental-test-module-mocks` flag automatically. Use dynamic
367
+ `import(...)` inside `execute` — static imports happen too early.
368
+
369
+ ```ts
370
+ import { mock } from 'node:test';
371
+ import { defineEval } from '@ls-stack/agent-eval';
372
+
373
+ defineEval({
374
+ id: 'module-mock-demo',
375
+ cases: [{ id: 'mocked-dependency', input: { customerId: 'vip-100' } }],
376
+ execute: async ({ input, setOutput }) => {
377
+ mock.module('../src/customerLookup.ts', {
378
+ namedExports: { lookupCustomer: async () => ({ segment: 'vip' }) },
379
+ });
380
+ const { runWorkflow } = await import('../src/workflow.ts');
381
+ const result = await runWorkflow(input);
382
+ setOutput('segment', result.segment);
383
+ },
384
+ });
385
+ ```
386
+
387
+ ## Workflow checklist
388
+
389
+ When adding or changing evals:
390
+
391
+ 1. Put the tracing + ambient SDK calls in the product code that runs in both
392
+ production and evals. Keep eval files thin.
393
+ 2. Use realistic cases drawn from real product flows; avoid placeholder inputs.
394
+ 3. `evalAssert` for hard invariants, `scores` for graded signals,
395
+ `passThreshold` only on scores that should gate pass/fail.
396
+ 4. Surface reviewable values through execute-context `setOutput` or ambient
397
+ `setEvalOutput` in shared workflow code, and shape them with `columns`
398
+ formats from the `ColumnFormat` type.
399
+ 5. Promote high-signal span attributes with `traceDisplay` so they surface in
400
+ the trace tree and detail pane.
401
+ 6. Cache costly pure spans with `cache: { key }` and pure spanless values with
402
+ `evalTracer.cache(...)`; never cache operations whose external side effects
403
+ you depend on.
404
+ 7. Sanity-check after changes: `agent-evals list`, then
405
+ `agent-evals run --eval <id>`.
406
+ 8. Locate saved artifacts with `agent-evals show-runs latest --json`, then read
407
+ the relevant `summary.json`, `cases.jsonl`, `case-details/<case-id>.json`,
408
+ or `traces/<case-id>.json` file directly.