diff --git a/apps/dev-playground/config/agents/query/evals/dataset.eval.ts b/apps/dev-playground/config/agents/query/evals/dataset.eval.ts index 98cdd9d3..25184283 100644 --- a/apps/dev-playground/config/agents/query/evals/dataset.eval.ts +++ b/apps/dev-playground/config/agents/query/evals/dataset.eval.ts @@ -1,4 +1,8 @@ -import { defineEval, isJudgeConfigured } from "@databricks/appkit/beta"; +import { + defineEval, + isJudgeConfigured, + userTurns, +} from "@databricks/appkit/beta"; /** * Dataset-driven eval: runs once per row of a Databricks managed evaluation @@ -13,17 +17,12 @@ import { defineEval, isJudgeConfigured } from "@databricks/appkit/beta"; * Row shape produced by the MLflow managed-dataset UI: * inputs {"messages":[{"role":"user","content":"..."}]} * expectations {"guidelines":{"value":["...","..."]}} (optional) + * + * A row's `messages` can be a full multi-turn conversation. We replay each USER + * turn in order against one shared thread (below); interleaved assistant turns + * in the row are ignored — the agent generates its own responses. */ -/** Pull the last user message out of an MLflow `{messages:[...]}` input. */ -function userMessage(input: Record): string { - const messages = Array.isArray(input.messages) - ? (input.messages as Array<{ role?: string; content?: string }>) - : []; - const last = [...messages].reverse().find((m) => m.role === "user"); - return last?.content ?? ""; -} - /** Read `expectations.guidelines` — the UI wraps the array as `{value: [...]}`. */ function guidelines(expected: Record | undefined): string[] { const g = (expected?.guidelines as { value?: unknown } | undefined)?.value; @@ -35,9 +34,12 @@ export default defineEval({ // Point at your own managed evaluation dataset (catalog.schema.table). dataset: { table: "main.mario.appkit_eval_dataset" }, async test(t) { - // One turn per row. For a multi-turn conversation, call `t.send` again - // (same thread); to start an independent turn in the same test, `t.reset()`. - await t.send(userMessage(t.input)); + // Replay every user turn in the row against one thread, so the agent sees + // the accumulating conversation. A single-user-turn row sends once. The + // runner gives each row a fresh driver, so rows don't bleed into each other. + for (const turn of userTurns(t.input)) { + await t.send(turn); + } t.succeeded(); // Each guideline is judged against the reply — gate by default, so a miss diff --git a/packages/appkit/src/evals/dataset.ts b/packages/appkit/src/evals/dataset.ts index dc0cac13..b2137a65 100644 --- a/packages/appkit/src/evals/dataset.ts +++ b/packages/appkit/src/evals/dataset.ts @@ -21,6 +21,25 @@ export interface ReadEvalDatasetOptions { limit?: number; } +/** + * Extract every user-message content, in order, from an MLflow + * `{"messages":[{"role":"user","content":"..."}]}` input. A dataset row can + * carry a full multi-turn conversation; replaying these against one thread (one + * `t.send` per returned string) lets the agent see the accumulating history. + * + * Only `role === "user"` turns are returned — any interleaved `assistant`/ + * `system` messages in the row are ignored, since the agent generates its own + * responses; you never inject the dataset's assistant turns. A single-user-turn + * row yields a one-element array (backward compatible); a row with no `messages` + * yields `[]`. + */ +export function userTurns(input: Record): string[] { + const messages = Array.isArray(input.messages) + ? (input.messages as Array<{ role?: string; content?: string }>) + : []; + return messages.filter((m) => m.role === "user").map((m) => m.content ?? ""); +} + /** A managed eval dataset is a UC table; only 3-level names are valid. */ const UC_TABLE = /^[A-Za-z0-9_]+\.[A-Za-z0-9_]+\.[A-Za-z0-9_]+$/; diff --git a/packages/appkit/src/evals/discover.ts b/packages/appkit/src/evals/discover.ts index e60ae403..33cacf0f 100644 --- a/packages/appkit/src/evals/discover.ts +++ b/packages/appkit/src/evals/discover.ts @@ -11,6 +11,14 @@ export interface DiscoveredEval { agent: string; } +/** A per-agent `evals.config.ts` found under `config/agents//evals/`. */ +export interface DiscoveredEvalConfig { + /** Absolute path to the `evals.config.ts` file. */ + file: string; + /** The agent id whose evals this config applies to. */ + agent: string; +} + function isDir(p: string): boolean { try { return statSync(p).isDirectory(); @@ -19,6 +27,14 @@ function isDir(p: string): boolean { } } +function isFile(p: string): boolean { + try { + return statSync(p).isFile(); + } catch { + return false; + } +} + /** Recursively collect `*.eval.ts` files (skips `evals.config.ts`). */ function walkEvalFiles(dir: string): string[] { const out: string[] = []; @@ -74,3 +90,30 @@ export function discoverEvalFiles(rootDir: string): DiscoveredEval[] { (a, b) => a.agent.localeCompare(b.agent) || a.id.localeCompare(b.id), ); } + +/** + * Discover the per-agent `evals.config.ts` (from {@link defineEvalConfig}) at + * `/config/agents//evals/evals.config.ts`. Config is per-agent: + * each agent's config applies only to that agent's evals. Agents without a + * config file are omitted. Returns a stable, sorted list. + */ +export function discoverEvalConfigs(rootDir: string): DiscoveredEvalConfig[] { + const agentsDir = path.join(rootDir, "config", "agents"); + const out: DiscoveredEvalConfig[] = []; + + let agents: string[]; + try { + agents = readdirSync(agentsDir).filter((n) => + isDir(path.join(agentsDir, n)), + ); + } catch { + return out; + } + + for (const agent of agents) { + const file = path.join(agentsDir, agent, "evals", "evals.config.ts"); + if (isFile(file)) out.push({ file, agent }); + } + + return out.sort((a, b) => a.agent.localeCompare(b.agent)); +} diff --git a/packages/appkit/src/evals/http-driver.ts b/packages/appkit/src/evals/http-driver.ts index f4d8e402..7131797c 100644 --- a/packages/appkit/src/evals/http-driver.ts +++ b/packages/appkit/src/evals/http-driver.ts @@ -13,13 +13,30 @@ export interface HttpDriverOptions { mlflowRunId?: string; } +/** A single captured tool call, deduped by `call_id ?? name`. */ +type ToolCall = { name: string; args: Record }; + +/** Parse a function-call `arguments` JSON string; `{}` on missing/invalid. */ +function parseArgs(raw: unknown): Record { + if (typeof raw !== "string" || raw.trim() === "") return {}; + try { + const parsed = JSON.parse(raw); + return parsed !== null && + typeof parsed === "object" && + !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + /** Parse a single Responses-API SSE `data:` payload into the running totals. */ function applyEvent( event: Record, state: { reply: string; - toolCalls: string[]; - seen: Set; + toolCalls: Map; ok: boolean; traceId?: string; }, @@ -38,13 +55,18 @@ function applyEvent( type === "response.output_item.done" ) { const item = event.item as - | { type?: string; name?: string; call_id?: string } + | { type?: string; name?: string; call_id?: string; arguments?: string } | undefined; if (item?.type === "function_call" && item.name) { const key = item.call_id ?? item.name; - if (!state.seen.has(key)) { - state.seen.add(key); - state.toolCalls.push(item.name); + const args = parseArgs(item.arguments); + const existing = state.toolCalls.get(key); + if (!existing) { + state.toolCalls.set(key, { name: item.name, args }); + } else if (Object.keys(args).length > 0) { + // The initial `added` event may carry empty args while the later + // `done` carries the full arguments — keep the fuller set. + existing.args = args; } } return; @@ -92,13 +114,19 @@ export function createHttpDriver(options: HttpDriverOptions): EvalDriver { }), }); } catch { - return { reply: "", toolCalls: [], succeeded: false }; + return { + reply: "", + toolCalls: [], + toolCallDetails: [], + succeeded: false, + }; } if (!res.ok || !res.body) { return { reply: "", toolCalls: [], + toolCallDetails: [], succeeded: false, sessionId: threadId, }; @@ -106,8 +134,7 @@ export function createHttpDriver(options: HttpDriverOptions): EvalDriver { const state = { reply: "", - toolCalls: [] as string[], - seen: new Set(), + toolCalls: new Map(), ok: true, traceId: undefined as string | undefined, }; @@ -139,9 +166,11 @@ export function createHttpDriver(options: HttpDriverOptions): EvalDriver { reader.releaseLock(); } + const toolCallDetails = [...state.toolCalls.values()]; return { reply: state.reply, - toolCalls: state.toolCalls, + toolCalls: toolCallDetails.map((c) => c.name), + toolCallDetails, succeeded: state.ok, sessionId: threadId, traceId: state.traceId, diff --git a/packages/appkit/src/evals/index.ts b/packages/appkit/src/evals/index.ts index 5f3258e3..0fa5aadb 100644 --- a/packages/appkit/src/evals/index.ts +++ b/packages/appkit/src/evals/index.ts @@ -11,9 +11,15 @@ export { type DatasetRow, type ReadEvalDatasetOptions, readEvalDataset, + userTurns, } from "./dataset"; export { defineEval, defineEvalConfig } from "./define-eval"; -export { type DiscoveredEval, discoverEvalFiles } from "./discover"; +export { + type DiscoveredEval, + type DiscoveredEvalConfig, + discoverEvalConfigs, + discoverEvalFiles, +} from "./discover"; export { createHttpDriver, type HttpDriverOptions } from "./http-driver"; export { configureJudge, @@ -34,6 +40,8 @@ export { formatEvalDetail, formatEvalHeadline, formatEvalResults, + formatResultsJson, + formatResultsJUnit, formatSummaryLine, summarize, } from "./report"; @@ -42,7 +50,9 @@ export { type EvalProgress, type EvalRunSummary, type RunEvalsOptions, + runBounded, runEvalsInDir, + runWithRetries, } from "./run-evals"; export type { AssertionHandle, diff --git a/packages/appkit/src/evals/report.ts b/packages/appkit/src/evals/report.ts index 2e42c5b1..8b903f0d 100644 --- a/packages/appkit/src/evals/report.ts +++ b/packages/appkit/src/evals/report.ts @@ -7,6 +7,8 @@ export interface EvalSummary { skipped: number; /** True when no eval failed (skips don't count as failures). */ allPassed: boolean; + /** Fraction of scored (non-skipped) evals that passed, 0..1 (1 when none scored). */ + passRate: number; } export function summarize(results: EvalResult[]): EvalSummary { @@ -18,12 +20,14 @@ export function summarize(results: EvalResult[]): EvalSummary { else if (r.passed) passed++; else failed++; } + const scored = passed + failed; return { total: results.length, passed, failed, skipped, allPassed: failed === 0, + passRate: scored === 0 ? 1 : passed / scored, }; } @@ -74,3 +78,67 @@ export function formatEvalResults(results: EvalResult[]): string { lines.push(formatSummaryLine(results)); return lines.join("\n"); } + +/** + * Render results as a machine-readable JSON report (2-space indented): + * `{ summary: EvalSummary, results: EvalResult[] }`. Faithful to the types — + * every field present on a result round-trips. + */ +export function formatResultsJson(results: EvalResult[]): string { + return JSON.stringify({ summary: summarize(results), results }, null, 2); +} + +/** Escape a value for use in XML text/attribute content. */ +function escapeXml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** One-line reason a result failed: its error, else its failing gate labels. */ +function failureMessage(result: EvalResult): string { + if (result.error) return result.error; + const gates = result.assertions + .filter((a) => !a.pass && a.severity === "gate") + .map((a) => (a.detail ? `${a.label} — ${a.detail}` : a.label)); + return gates.length ? gates.join("; ") : "eval failed"; +} + +/** + * Render results as JUnit XML for standard CI test reporters: a single + * `` with one `` per result. + * Failures carry a `` (error or failing-gate summary); skips a + * ``. All attribute/text values are XML-escaped. + */ +export function formatResultsJUnit(results: EvalResult[]): string { + const s = summarize(results); + const lines: string[] = []; + lines.push(''); + lines.push( + ``, + ); + for (const r of results) { + const open = ` `); + lines.push( + r.skipped.reason + ? ` ` + : " ", + ); + lines.push(" "); + } else if (!r.passed) { + const message = failureMessage(r); + lines.push(`${open}>`); + lines.push(` `); + lines.push(" "); + } else { + lines.push(`${open}/>`); + } + } + lines.push(""); + return lines.join("\n"); +} diff --git a/packages/appkit/src/evals/run-eval.ts b/packages/appkit/src/evals/run-eval.ts index c0ec2a57..7129c9e4 100644 --- a/packages/appkit/src/evals/run-eval.ts +++ b/packages/appkit/src/evals/run-eval.ts @@ -3,6 +3,7 @@ import { judgeClosedQA, judgeCustom, judgeFactuality } from "./judge"; import type { AssertionHandle, AssertionResult, + DriveResult, EvalDefinition, EvalDriver, EvalResult, @@ -21,6 +22,32 @@ class SkipSignal extends Error { } } +/** Rejects the test race when a per-eval timeout elapses. */ +class TimeoutSignal extends Error { + constructor(ms: number) { + super(`eval timed out after ${ms}ms`); + this.name = "TimeoutSignal"; + } +} + +/** + * Deep partial match: every key in `expected` is present in `actual` and equal, + * recursing into nested plain objects so extra actual keys are ignored. + */ +function deepContains(actual: unknown, expected: unknown): boolean { + if (isPlainObject(expected)) { + if (!isPlainObject(actual)) return false; + return Object.keys(expected).every((key) => + deepContains(actual[key], expected[key]), + ); + } + return actual === expected; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + export interface RunEvalOptions { /** Stable id for the eval (e.g. its file path relative to the evals dir). */ id: string; @@ -30,6 +57,11 @@ export interface RunEvalOptions { strict?: boolean; /** Dataset row bound to `t.input`/`t.expected` for dataset-driven evals. */ row?: DatasetRow; + /** + * Runner-level default per-eval timeout (ms). `def.timeoutMs` wins over this; + * when both are unset the eval runs unbounded (current behavior). + */ + timeoutMs?: number; } /** @@ -45,6 +77,7 @@ export async function runEval( let reply = ""; let lastInput = ""; let toolCalls: string[] = []; + let toolCallDetails: DriveResult["toolCallDetails"] = []; let sessionId: string | undefined; let lastTraceId: string | undefined; let lastSucceeded = false; @@ -98,6 +131,7 @@ export async function runEval( const r = await options.driver.send(message); reply = r.reply; toolCalls = r.toolCalls; + toolCallDetails = r.toolCallDetails; sessionId = r.sessionId; lastSucceeded = r.succeeded; if (r.traceId) lastTraceId = r.traceId; @@ -138,6 +172,21 @@ export async function runEval( })`, ); }, + calledToolWith(name, expected) { + const matching = toolCallDetails.filter((c) => c.name === name); + const pass = matching.some((c) => deepContains(c.args, expected)); + const seen = matching.length + ? matching.map((c) => JSON.stringify(c.args)).join(", ") + : "not called"; + return record( + `calledToolWith(${name})`, + pass, + undefined, + `expected tool "${name}" to be called with ${JSON.stringify( + expected, + )} (args seen: ${seen})`, + ); + }, check(value: string, matcher: Matcher) { const m = matcher(value); return record("check", m.pass, m.score, m.detail); @@ -172,8 +221,26 @@ export async function runEval( }, }; + // `def.timeoutMs` (per-eval) wins over the runner default; when both are + // unset the eval runs unbounded (undefined = no timeout). + const timeoutMs = def.timeoutMs ?? options.timeoutMs; + let timer: ReturnType | undefined; + try { - await def.test(t); + if (timeoutMs === undefined) { + await def.test(t); + } else { + // Race the test against a timeout; on elapse the sentinel rejects and we + // convert it to a non-passing result. The timer is cleared in `finally` + // so it can't keep the process alive after the test settles. + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new TimeoutSignal(timeoutMs)), + timeoutMs, + ); + }); + await Promise.race([Promise.resolve(def.test(t)), timeout]); + } } catch (err) { if (err instanceof SkipSignal) { return { @@ -193,6 +260,8 @@ export async function runEval( error: err instanceof Error ? err.message : String(err), traceId: lastTraceId, }; + } finally { + if (timer) clearTimeout(timer); } const passed = assertions.every( diff --git a/packages/appkit/src/evals/run-evals.ts b/packages/appkit/src/evals/run-evals.ts index c304e454..1bd39338 100644 --- a/packages/appkit/src/evals/run-evals.ts +++ b/packages/appkit/src/evals/run-evals.ts @@ -2,13 +2,17 @@ import { pathToFileURL } from "node:url"; import type { WorkspaceClient } from "@databricks/sdk-experimental"; import { MlflowClient } from "../connectors/mlflow"; import { type DatasetRow, readEvalDataset } from "./dataset"; -import { discoverEvalFiles } from "./discover"; +import { + type DiscoveredEval, + discoverEvalConfigs, + discoverEvalFiles, +} from "./discover"; import { createHttpDriver } from "./http-driver"; import { configureJudge } from "./judge"; import { type ReportOutcome, reportToMlflow } from "./mlflow-report"; import { createEvalRun, type FinishOutcome, finishEvalRun } from "./mlflow-run"; import { runEval } from "./run-eval"; -import type { EvalDefinition, EvalResult } from "./types"; +import type { EvalConfig, EvalDefinition, EvalResult } from "./types"; export interface RunEvalsOptions { /** Project root containing `config/agents/`. Defaults to `process.cwd()`. */ @@ -17,6 +21,11 @@ export interface RunEvalsOptions { baseUrl: string; /** Substring filter on `/` (or an exact agent id). */ filter?: string; + /** + * Only run evals whose `tags` intersect this list. Empty/undefined runs all. + * Tags live on the eval def, so filtering happens after each file is loaded. + */ + tags?: string[]; /** Soft assertion failures also fail the eval. */ strict?: boolean; /** Extra request headers for the driver (e.g. auth for a deployed app). */ @@ -41,6 +50,24 @@ export interface RunEvalsOptions { warehouseId?: string; /** Wall-clock timestamp (ms) for run create/finish — pass `Date.now()`. */ now?: number; + /** + * Max evals/dataset rows to drive concurrently. Defaults to `1` (serial). + * Values below 1 are clamped to 1. Output order is preserved regardless. + * Wins over an agent's `evals.config.ts` `maxConcurrency`. + */ + maxConcurrency?: number; + /** + * Default per-eval timeout (ms). A per-eval `def.timeoutMs` overrides it. + * Wins over an agent's `evals.config.ts` `timeoutMs`. + */ + timeoutMs?: number; + /** + * Re-run an eval up to this many extra times when it fails on an + * infrastructure error (a thrown error or timeout — `result.error` set), to + * absorb transient turn/stream flakiness. Assertion failures are NEVER + * retried (a wrong reply is real signal, not flake). Defaults to `0`. + */ + retries?: number; /** Progress callback, invoked as evals are discovered, started, and finished. */ onEvent?: (event: EvalProgress) => void; } @@ -58,12 +85,11 @@ export interface EvalRunSummary { } /** - * Load a `*.eval.ts` file and return its default-exported {@link EvalDefinition}. - * Uses tsx's programmatic loader so TypeScript eval files run without a build - * step. The specifier is indirected so the type checker doesn't try to resolve - * tsx's internal entry. + * Import a TypeScript file with tsx's programmatic loader so eval files run + * without a build step. The specifier is indirected so the type checker doesn't + * try to resolve tsx's internal entry. */ -async function loadEval(file: string): Promise { +async function tsImportFile(file: string): Promise { const tsxApi = "tsx/esm/api"; let tsImport: (specifier: string, parentURL: string) => Promise; try { @@ -75,8 +101,14 @@ async function loadEval(file: string): Promise { "Running .eval.ts files requires `tsx`. Install it as a dev dependency (`pnpm add -D tsx`).", ); } + return tsImport(pathToFileURL(file).href, import.meta.url); +} - const mod = await tsImport(pathToFileURL(file).href, import.meta.url); +/** + * Load a `*.eval.ts` file and return its default-exported {@link EvalDefinition}. + */ +async function loadEval(file: string): Promise { + const mod = await tsImportFile(file); const def = resolveEvalDefault(mod); if (!def) { throw new Error(`${file}: must default-export defineEval({ test })`); @@ -84,6 +116,37 @@ async function loadEval(file: string): Promise { return def; } +/** + * Load an `evals.config.ts` file and return its default-exported + * {@link EvalConfig}. A malformed/missing default surfaces as `undefined` so a + * bad config never aborts a whole run. + */ +async function loadEvalConfig(file: string): Promise { + const mod = await tsImportFile(file); + return resolveConfigDefault(mod); +} + +/** + * Unwrap the config default export across module-interop shapes (see + * {@link resolveEvalDefault}). A config has no `.test`, so the first plain + * object reached through the `default` chain is taken as the config. + */ +export function resolveConfigDefault(mod: unknown): EvalConfig | undefined { + const seen = new Set(); + let candidate: unknown = mod; + for (let i = 0; i < 4 && candidate && !seen.has(candidate); i++) { + const next = (candidate as { default?: unknown }).default; + if (next === undefined) { + return typeof candidate === "object" + ? (candidate as EvalConfig) + : undefined; + } + seen.add(candidate); + candidate = next; + } + return undefined; +} + /** * Unwrap the eval default export across module-interop shapes. Depending on * whether the eval file is treated as ESM or CJS, the value lands at @@ -103,6 +166,85 @@ export function resolveEvalDefault(mod: unknown): EvalDefinition | undefined { return undefined; } +/** + * Run `tasks` through a bounded worker pool and return their results in the + * SAME order as the input, regardless of completion order. Each task receives + * its input index so callers can key on it. `limit` is clamped to at least 1 + * (and to the task count); at `limit === 1` this is a serial loop. Individual + * task rejections are surfaced per-slot via `settle` rather than aborting + * siblings — but eval tasks never reject (failures become results). + */ +export async function runBounded( + tasks: ReadonlyArray, + limit: number, + worker: (task: T, index: number) => Promise, +): Promise { + const results = new Array(tasks.length); + const workers = Math.max(1, Math.min(Math.floor(limit) || 1, tasks.length)); + let next = 0; + async function pump(): Promise { + // Each worker pulls the next unclaimed index until the queue drains, so a + // fast task immediately picks up more work instead of waiting on siblings. + while (next < tasks.length) { + const index = next++; + results[index] = await worker(tasks[index], index); + } + } + await Promise.all(Array.from({ length: workers }, () => pump())); + return results; +} + +/** + * Run `attempt` up to `1 + retries` times, stopping as soon as it returns a + * result without an `error` (infra failures — thrown errors or timeouts — set + * `error`; assertion failures do not, so a failed-but-completed eval is returned + * on the first try and never retried). Returns the last result when every + * attempt errored. `retries` below 0 is treated as 0. + */ +export async function runWithRetries( + retries: number, + attempt: (attemptNumber: number) => Promise, +): Promise { + const maxAttempts = 1 + Math.max(0, retries); + let result: EvalResult; + for (let n = 1; ; n++) { + result = await attempt(n); + if (!result.error || n >= maxAttempts) return result; + } +} + +/** + * Whether an eval's `tags` satisfy a `--tag` filter: `true` when the filter is + * empty/undefined (no filtering), otherwise only when the eval shares at least + * one tag with it. An eval with no tags never matches a non-empty filter. + */ +export function matchesTags( + defTags: string[] | undefined, + filterTags: string[] | undefined, +): boolean { + if (!filterTags || filterTags.length === 0) return true; + return defTags?.some((t) => filterTags.includes(t)) ?? false; +} + +/** One unit of work: a single {@link runEval} call bound to its output slot. */ +interface WorkItem { + /** File index (used for progress `index`, matches serial behavior). */ + index: number; + /** Display id, including the `[row i/n]` suffix for dataset rows. */ + rowId: string; + def: EvalDefinition; + /** Parent dir agent, used when `def.agent` is unset. */ + dirAgent: string; + row: DatasetRow | undefined; + /** + * Runner-level default timeout (ms) for this item, resolved from CLI options + * then the agent's `evals.config.ts`. `def.timeoutMs` still overrides it. + */ + timeoutMs?: number; + /** Set when the file failed to load or the dataset failed to read. */ + error?: string; +} + /** * Discover, load, and run every eval under each agent's `evals/` dir, driving * the agents on a running app. Never throws for an individual eval — load/run @@ -123,7 +265,59 @@ export async function runEvalsInDir( } const emit = options.onEvent ?? (() => {}); - const total = discovered.length; + + // Load each agent's `evals.config.ts` (best-effort, per-agent): its settings + // apply only to that agent's evals. A malformed/missing config never aborts + // the run — the agent just falls back to CLI options and built-in defaults. + const configs = new Map(); + for (const c of discoverEvalConfigs(root)) { + try { + const cfg = await loadEvalConfig(c.file); + if (cfg) configs.set(c.agent, cfg); + } catch { + // Ignore: fall back to CLI options / defaults for this agent. + } + } + + // Load each eval def and apply the `--tag` filter up front. Tags live on the + // def, so a tag miss removes the eval entirely (like the substring filter + // excludes files) rather than surfacing as a result. Load failures are kept + // so a broken file still reports as a non-passing result. + const loaded: Array<{ + d: DiscoveredEval; + def: EvalDefinition; + loadError?: string; + }> = []; + for (const d of discovered) { + let def: EvalDefinition; + try { + def = await loadEval(d.file); + } catch (err) { + loaded.push({ + d, + // No def loaded; placeholder def is never run (error short-circuits). + def: { test: () => {} }, + loadError: err instanceof Error ? err.message : String(err), + }); + continue; + } + if (!matchesTags(def.tags, options.tags)) continue; + loaded.push({ d, def }); + } + + // `evals.config.ts` `maxConcurrency` governs the single shared work pool, so + // it can't be applied per-agent without splitting the pool. CLI wins; else + // the highest value any agent's config requests (the pool ceiling); else 1. + const configMaxConcurrency = [...configs.values()] + .map((c) => c.maxConcurrency) + .filter((n): n is number => typeof n === "number") + .reduce( + (max, n) => (max === undefined ? n : Math.max(max, n)), + undefined, + ); + const maxConcurrency = options.maxConcurrency ?? configMaxConcurrency ?? 1; + + const total = loaded.length; emit({ type: "discovered", total }); if (options.judge) { @@ -149,26 +343,30 @@ export async function runEvalsInDir( emit({ type: "run-created", runId }); } - const results: EvalResult[] = []; - // `total` counts eval files, not dataset rows: row counts aren't known until - // each file loads. Per-row detail is carried in the result id (`[row i/n]`). - for (let index = 0; index < discovered.length; index++) { - const d = discovered[index]; + // Expand the loaded evals into a flat, ordered work list SERIALLY: reading a + // dataset is cheap next to the agent turns, and doing it in order preserves + // both output ordering and error handling (a dataset failure becomes a + // non-passing result in its own slot). `total` counts eval files, not dataset + // rows: per-row detail is carried in the result id (`[row i/n]`). + const items: WorkItem[] = []; + for (let index = 0; index < loaded.length; index++) { + const { d, def, loadError } = loaded[index]; const id = `${d.agent}/${d.id}`; - let def: EvalDefinition | undefined; - try { - def = await loadEval(d.file); - } catch (err) { - emit({ type: "start", id, index, total }); - const result: EvalResult = { - id, - assertions: [], - passed: false, - error: err instanceof Error ? err.message : String(err), - }; - results.push(result); - emit({ type: "result", result, index, total }); + // Runner default timeout for this agent: CLI wins over its config value; + // a per-eval `def.timeoutMs` overrides both (applied inside runEval). + const timeoutMs = options.timeoutMs ?? configs.get(d.agent)?.timeoutMs; + + if (loadError) { + items.push({ + index, + rowId: id, + def, + dirAgent: d.agent, + row: undefined, + timeoutMs, + error: loadError, + }); continue; } @@ -199,39 +397,62 @@ export async function runEvalsInDir( def.dataset && rows.length > 1 ? `${id} [row ${r + 1}/${rows.length}]` : id; - emit({ type: "start", id: rowId, index, total }); + items.push({ + index, + rowId, + def, + dirAgent: d.agent, + row: rows[r], + timeoutMs, + error: datasetError, + }); + } + } + + // Run the per-row `runEval` calls through a bounded pool. `runBounded` + // places each result in its input slot, so `results` keeps discovery/row + // order regardless of completion order. Progress events fire live and may + // interleave when maxConcurrency > 1. + const results = await runBounded( + items, + maxConcurrency, + async (item): Promise => { + emit({ type: "start", id: item.rowId, index: item.index, total }); let result: EvalResult; - if (datasetError) { + if (item.error) { result = { - id: rowId, + id: item.rowId, assertions: [], passed: false, - error: datasetError, + error: item.error, }; } else { - // A fresh driver per row: each row is an independent conversation, so - // its thread must not carry over the previous row's history. (Multiple - // `t.send`s within one row still share the thread — that's the driver's - // per-instance behavior.) - const driver = createHttpDriver({ - baseUrl: options.baseUrl, - agent: def.agent ?? d.agent, - headers: options.headers, - mlflowRunId: runId, - }); - result = await runEval(def, { - id: rowId, - driver, - strict: options.strict, - row: rows[r], - }); + // Retry only on an infrastructure error (`result.error` — a thrown + // error or timeout), to absorb transient turn/stream flakiness; + // assertion failures are real signal and returned on the first try. + // Each attempt gets a fresh driver so its thread doesn't carry over the + // failed attempt's history. + result = await runWithRetries(options.retries ?? 0, () => + runEval(item.def, { + id: item.rowId, + driver: createHttpDriver({ + baseUrl: options.baseUrl, + agent: item.def.agent ?? item.dirAgent, + headers: options.headers, + mlflowRunId: runId, + }), + strict: options.strict, + row: item.row, + timeoutMs: item.timeoutMs, + }), + ); } - results.push(result); - emit({ type: "result", result, index, total }); - } - } + emit({ type: "result", result, index: item.index, total }); + return result; + }, + ); if (mlflowClient && runId) { const report = await reportToMlflow(mlflowClient, results); diff --git a/packages/appkit/src/evals/tests/dataset.test.ts b/packages/appkit/src/evals/tests/dataset.test.ts index b6787cd8..3c912cd6 100644 --- a/packages/appkit/src/evals/tests/dataset.test.ts +++ b/packages/appkit/src/evals/tests/dataset.test.ts @@ -8,7 +8,7 @@ vi.mock("../../connectors", () => ({ }, })); -import { readEvalDataset } from "../dataset"; +import { readEvalDataset, userTurns } from "../dataset"; const client = {} as never; @@ -90,3 +90,45 @@ describe("readEvalDataset", () => { expect(executeStatement).not.toHaveBeenCalled(); }); }); + +describe("userTurns", () => { + test("returns all user contents in order", () => { + expect( + userTurns({ + messages: [ + { role: "user", content: "first" }, + { role: "user", content: "second" }, + ], + }), + ).toEqual(["first", "second"]); + }); + + test("ignores assistant/system turns, keeps user order", () => { + expect( + userTurns({ + messages: [ + { role: "system", content: "be helpful" }, + { role: "user", content: "hi" }, + { role: "assistant", content: "hello" }, + { role: "user", content: "follow up" }, + ], + }), + ).toEqual(["hi", "follow up"]); + }); + + test("a single user message yields one turn", () => { + expect( + userTurns({ messages: [{ role: "user", content: "only" }] }), + ).toEqual(["only"]); + }); + + test("missing content becomes an empty string", () => { + expect(userTurns({ messages: [{ role: "user" }] })).toEqual([""]); + }); + + test("missing or non-array messages yields []", () => { + expect(userTurns({})).toEqual([]); + expect(userTurns({ messages: "nope" })).toEqual([]); + expect(userTurns({ messages: [] })).toEqual([]); + }); +}); diff --git a/packages/appkit/src/evals/tests/discover.test.ts b/packages/appkit/src/evals/tests/discover.test.ts index 6eb7c220..aefc64ca 100644 --- a/packages/appkit/src/evals/tests/discover.test.ts +++ b/packages/appkit/src/evals/tests/discover.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { discoverEvalFiles } from "../discover"; +import { discoverEvalConfigs, discoverEvalFiles } from "../discover"; let root: string; @@ -40,3 +40,22 @@ describe("discoverEvalFiles", () => { expect(discoverEvalFiles(root)).toEqual([]); }); }); + +describe("discoverEvalConfigs", () => { + test("finds each agent's evals.config.ts, omits agents without one", () => { + write("config/agents/support/evals/basic.eval.ts"); + write("config/agents/support/evals/evals.config.ts"); + write("config/agents/analyst/evals/sql.eval.ts"); + + const found = discoverEvalConfigs(root); + + expect(found.map((c) => c.agent)).toEqual(["support"]); + expect(found[0].file).toBe( + path.join(root, "config/agents/support/evals/evals.config.ts"), + ); + }); + + test("returns empty when there is no config/agents dir", () => { + expect(discoverEvalConfigs(root)).toEqual([]); + }); +}); diff --git a/packages/appkit/src/evals/tests/http-driver.test.ts b/packages/appkit/src/evals/tests/http-driver.test.ts new file mode 100644 index 00000000..331ee212 --- /dev/null +++ b/packages/appkit/src/evals/tests/http-driver.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { createHttpDriver } from "../http-driver"; + +/** Build a mock SSE `Response` from a list of Responses-API events. */ +function sseResponse(events: Array>): Response { + const body = events.map((e) => `data: ${JSON.stringify(e)}\n`).join("\n"); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(body)); + controller.close(); + }, + }); + return new Response(stream, { status: 200 }); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("createHttpDriver", () => { + test("captures tool-call names and parses their arguments", async () => { + // `added` carries empty args; `done` carries the full JSON string. + vi.spyOn(globalThis, "fetch").mockResolvedValue( + sseResponse([ + { + type: "response.output_item.added", + item: { + type: "function_call", + name: "get_weather", + call_id: "c1", + arguments: "", + }, + }, + { + type: "response.output_item.done", + item: { + type: "function_call", + name: "get_weather", + call_id: "c1", + arguments: '{"city":"Paris","units":"metric"}', + }, + }, + { type: "response.output_text.delta", delta: "Sunny" }, + ]), + ); + + const driver = createHttpDriver({ baseUrl: "http://localhost:3000" }); + const result = await driver.send("weather in Paris?"); + + expect(result.reply).toBe("Sunny"); + expect(result.toolCalls).toEqual(["get_weather"]); + expect(result.toolCallDetails).toEqual([ + { name: "get_weather", args: { city: "Paris", units: "metric" } }, + ]); + expect(result.succeeded).toBe(true); + }); + + test("defaults args to {} when the arguments JSON is malformed", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + sseResponse([ + { + type: "response.output_item.done", + item: { + type: "function_call", + name: "broken", + call_id: "c1", + arguments: "{not json", + }, + }, + ]), + ); + + const driver = createHttpDriver({ baseUrl: "http://localhost:3000" }); + const result = await driver.send("go"); + + expect(result.toolCallDetails).toEqual([{ name: "broken", args: {} }]); + }); +}); diff --git a/packages/appkit/src/evals/tests/report.test.ts b/packages/appkit/src/evals/tests/report.test.ts index c02c6884..58719361 100644 --- a/packages/appkit/src/evals/tests/report.test.ts +++ b/packages/appkit/src/evals/tests/report.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "vitest"; -import { formatEvalResults, summarize } from "../report"; +import { + formatEvalResults, + formatResultsJson, + formatResultsJUnit, + summarize, +} from "../report"; import type { EvalResult } from "../types"; const results: EvalResult[] = [ @@ -29,18 +34,25 @@ const results: EvalResult[] = [ ]; describe("eval reporting", () => { - test("summarize counts pass/fail/skip and allPassed", () => { + test("summarize counts pass/fail/skip, allPassed, and passRate", () => { expect(summarize(results)).toEqual({ total: 3, passed: 1, failed: 1, skipped: 1, allPassed: false, + passRate: 0.5, // 1 passed of 2 scored; the skip is excluded }); }); - test("summarize allPassed is true when nothing failed", () => { - expect(summarize([results[0], results[2]]).allPassed).toBe(true); + test("summarize allPassed is true and passRate is 1 when nothing failed", () => { + const s = summarize([results[0], results[2]]); + expect(s.allPassed).toBe(true); + expect(s.passRate).toBe(1); // 1 passed of 1 scored (skip excluded) + }); + + test("passRate is 1 when every eval was skipped (nothing scored)", () => { + expect(summarize([results[2]]).passRate).toBe(1); }); test("formatEvalResults shows status, failing assertions, and a summary line", () => { @@ -51,4 +63,73 @@ describe("eval reporting", () => { expect(out).toContain("a/skip (skipped: no data)"); expect(out).toContain("FAIL — 1 passed, 1 failed, 1 skipped (3 total)"); }); + + test("formatResultsJson round-trips summary and result fields", () => { + const parsed = JSON.parse(formatResultsJson(results)); + expect(parsed.summary).toEqual({ + total: 3, + passed: 1, + failed: 1, + skipped: 1, + allPassed: false, + passRate: 0.5, + }); + expect(parsed.results).toHaveLength(3); + const fail = parsed.results.find((r: EvalResult) => r.id === "a/fail"); + expect(fail.passed).toBe(false); + expect(fail.assertions).toEqual([ + { + label: "calledTool(x)", + severity: "gate", + pass: false, + detail: "not called", + }, + ]); + const skip = parsed.results.find((r: EvalResult) => r.id === "a/skip"); + expect(skip.skipped).toEqual({ reason: "no data" }); + }); + + test("formatResultsJson round-trips a result's error field", () => { + const errored: EvalResult[] = [ + { + id: "a/threw", + assertions: [], + passed: false, + error: "boom: turn failed", + }, + ]; + const parsed = JSON.parse(formatResultsJson(errored)); + expect(parsed.results[0].error).toBe("boom: turn failed"); + expect(parsed.summary.failed).toBe(1); + }); + + test("formatResultsJUnit emits suite counts, failure, skipped, and escapes special chars", () => { + const withSpecial: EvalResult[] = [ + ...results, + { + id: 'a/b & "q"', + assertions: [ + { + label: "check", + severity: "gate", + pass: false, + detail: 'reply had & "quote"', + }, + ], + passed: false, + }, + ]; + const xml = formatResultsJUnit(withSpecial); + expect(xml).toContain( + '', + ); + expect(xml).toContain(''); + expect(xml).toContain(""); + }); }); diff --git a/packages/appkit/src/evals/tests/resolve-default.test.ts b/packages/appkit/src/evals/tests/resolve-default.test.ts index 40079226..8c36bf13 100644 --- a/packages/appkit/src/evals/tests/resolve-default.test.ts +++ b/packages/appkit/src/evals/tests/resolve-default.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "vitest"; -import { resolveEvalDefault } from "../run-evals"; +import { + matchesTags, + resolveConfigDefault, + resolveEvalDefault, +} from "../run-evals"; const def = { description: "x", test: async () => {} }; @@ -23,3 +27,40 @@ describe("resolveEvalDefault (module interop)", () => { expect(resolveEvalDefault(null)).toBeUndefined(); }); }); + +const config = { maxConcurrency: 4, timeoutMs: 1000 }; + +describe("resolveConfigDefault (module interop)", () => { + test("pure ESM: mod.default", () => { + expect(resolveConfigDefault({ default: config })).toBe(config); + }); + + test("CJS __esModule double-wrap: mod.default.default", () => { + expect( + resolveConfigDefault({ default: { __esModule: true, default: config } }), + ).toBe(config); + }); + + test("no default export → undefined", () => { + expect(resolveConfigDefault(null)).toBeUndefined(); + expect(resolveConfigDefault(undefined)).toBeUndefined(); + }); +}); + +describe("matchesTags", () => { + test("no filter runs everything", () => { + expect(matchesTags(["a"], undefined)).toBe(true); + expect(matchesTags(undefined, [])).toBe(true); + expect(matchesTags(undefined, undefined)).toBe(true); + }); + + test("matches when tags intersect the filter", () => { + expect(matchesTags(["smoke", "slow"], ["smoke"])).toBe(true); + }); + + test("excludes when tags don't intersect or the def has none", () => { + expect(matchesTags(["slow"], ["smoke"])).toBe(false); + expect(matchesTags(undefined, ["smoke"])).toBe(false); + expect(matchesTags([], ["smoke"])).toBe(false); + }); +}); diff --git a/packages/appkit/src/evals/tests/run-bounded.test.ts b/packages/appkit/src/evals/tests/run-bounded.test.ts new file mode 100644 index 00000000..66b08135 --- /dev/null +++ b/packages/appkit/src/evals/tests/run-bounded.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from "vitest"; +import { runBounded, runWithRetries } from "../run-evals"; +import type { EvalResult } from "../types"; + +/** Resolve after `ms`, yielding to the event loop. */ +const delay = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +describe("runBounded", () => { + test("preserves input order regardless of completion order", async () => { + // Later items finish first, so completion order is the reverse of input. + const inputs = [40, 30, 20, 10, 0]; + const results = await runBounded(inputs, 5, async (ms, i) => { + await delay(ms); + return i; + }); + expect(results).toEqual([0, 1, 2, 3, 4]); + }); + + test("never exceeds the concurrency limit", async () => { + let active = 0; + let peak = 0; + const tasks = Array.from({ length: 12 }, (_, i) => i); + const results = await runBounded(tasks, 3, async (i) => { + active++; + peak = Math.max(peak, active); + await delay(5); + active--; + return i * 2; + }); + expect(peak).toBeLessThanOrEqual(3); + expect(results).toEqual(tasks.map((i) => i * 2)); + }); + + test("runs serially at limit 1 and clamps limits below 1", async () => { + const order: number[] = []; + for (const limit of [1, 0, -3]) { + order.length = 0; + let active = 0; + let peak = 0; + await runBounded([0, 1, 2], limit, async (i) => { + active++; + peak = Math.max(peak, active); + await delay(1); + order.push(i); + active--; + return i; + }); + expect(peak).toBe(1); + expect(order).toEqual([0, 1, 2]); + } + }); + + test("more workers than tasks is fine and stays ordered", async () => { + const results = await runBounded([2, 1], 10, async (ms, i) => { + await delay(ms); + return i; + }); + expect(results).toEqual([0, 1]); + }); + + test("empty task list resolves to an empty array", async () => { + const results = await runBounded([], 4, async (x) => x); + expect(results).toEqual([]); + }); +}); + +describe("runWithRetries", () => { + const errored = (n: number): EvalResult => ({ + id: `try-${n}`, + assertions: [], + passed: false, + error: "turn failed", + }); + const ok = (n: number): EvalResult => ({ + id: `try-${n}`, + assertions: [], + passed: true, + }); + const assertionFail = (n: number): EvalResult => ({ + id: `try-${n}`, + assertions: [{ label: "check", severity: "gate", pass: false }], + passed: false, + }); + + test("retries an infra error up to `retries` extra times, then returns the last", async () => { + let calls = 0; + const result = await runWithRetries(2, async (n) => { + calls = n; + return errored(n); + }); + expect(calls).toBe(3); // 1 initial + 2 retries + expect(result.error).toBe("turn failed"); + }); + + test("stops as soon as an attempt succeeds", async () => { + let calls = 0; + const result = await runWithRetries(5, async (n) => { + calls = n; + return n < 2 ? errored(n) : ok(n); + }); + expect(calls).toBe(2); // errored once, then ok + expect(result.passed).toBe(true); + }); + + test("never retries an assertion failure (no error set)", async () => { + let calls = 0; + const result = await runWithRetries(3, async (n) => { + calls = n; + return assertionFail(n); + }); + expect(calls).toBe(1); + expect(result.passed).toBe(false); + }); + + test("retries=0 runs exactly once", async () => { + let calls = 0; + await runWithRetries(0, async (n) => { + calls = n; + return errored(n); + }); + expect(calls).toBe(1); + }); +}); diff --git a/packages/appkit/src/evals/tests/run-eval.test.ts b/packages/appkit/src/evals/tests/run-eval.test.ts index 18970120..20c215ad 100644 --- a/packages/appkit/src/evals/tests/run-eval.test.ts +++ b/packages/appkit/src/evals/tests/run-eval.test.ts @@ -10,6 +10,7 @@ function fakeDriver(result: Partial): EvalDriver { send: async () => ({ reply: "", toolCalls: [], + toolCallDetails: [], succeeded: true, ...result, }), @@ -54,6 +55,82 @@ describe("runEval", () => { expect(result.assertions[0].pass).toBe(false); }); + test("calledToolWith passes when a call's args deep-contain the expected", async () => { + const def = defineEval({ + async test(t) { + await t.send("weather in Paris?"); + t.calledToolWith("get_weather", { city: "Paris" }); + }, + }); + const result = await runEval(def, { + id: "args-match", + driver: fakeDriver({ + toolCalls: ["get_weather"], + toolCallDetails: [ + { name: "get_weather", args: { city: "Paris", units: "metric" } }, + ], + }), + }); + expect(result.passed).toBe(true); + expect(result.assertions[0].pass).toBe(true); + }); + + test("calledToolWith fails when the tool was called with different args", async () => { + const def = defineEval({ + async test(t) { + await t.send("weather in Paris?"); + t.calledToolWith("get_weather", { city: "Paris" }); + }, + }); + const result = await runEval(def, { + id: "args-mismatch", + driver: fakeDriver({ + toolCalls: ["get_weather"], + toolCallDetails: [{ name: "get_weather", args: { city: "London" } }], + }), + }); + expect(result.passed).toBe(false); + expect(result.assertions[0].pass).toBe(false); + }); + + test("calledToolWith fails when the tool was not called", async () => { + const def = defineEval({ + async test(t) { + await t.send("hi"); + t.calledToolWith("get_weather", { city: "Paris" }); + }, + }); + const result = await runEval(def, { + id: "args-not-called", + driver: fakeDriver({ toolCalls: [], toolCallDetails: [] }), + }); + expect(result.passed).toBe(false); + expect(result.assertions[0].pass).toBe(false); + expect(result.assertions[0].detail).toContain("not called"); + }); + + test("calledToolWith matches nested args and ignores extra keys", async () => { + const def = defineEval({ + async test(t) { + await t.send("book it"); + t.calledToolWith("book", { where: { city: "Paris" } }); + }, + }); + const result = await runEval(def, { + id: "args-nested", + driver: fakeDriver({ + toolCalls: ["book"], + toolCallDetails: [ + { + name: "book", + args: { where: { city: "Paris", zip: "75001" }, when: "today" }, + }, + ], + }), + }); + expect(result.passed).toBe(true); + }); + test("soft failures don't fail the eval unless strict", async () => { const def = defineEval({ async test(t) { @@ -158,7 +235,12 @@ describe("runEval", () => { test("t.reset() forwards to the driver to start a fresh conversation", async () => { const reset = vi.fn(); const driver: EvalDriver = { - send: async () => ({ reply: "", toolCalls: [], succeeded: true }), + send: async () => ({ + reply: "", + toolCalls: [], + toolCallDetails: [], + succeeded: true, + }), reset, }; const def = defineEval({ @@ -186,4 +268,63 @@ describe("runEval", () => { }); expect(result.passed).toBe(true); }); + + test("def.timeoutMs turns a hanging test into a non-passing timeout result", async () => { + const def = defineEval({ + timeoutMs: 20, + async test() { + // Never resolves; only the timeout can settle the eval. + await new Promise(() => {}); + }, + }); + const result = await runEval(def, { id: "hang", driver: fakeDriver({}) }); + expect(result.passed).toBe(false); + expect(result.error).toBe("eval timed out after 20ms"); + }); + + test("a fast eval passes well under the same timeout", async () => { + const def = defineEval({ + timeoutMs: 20, + async test(t) { + await t.send("hi"); + t.succeeded(); + }, + }); + const result = await runEval(def, { + id: "fast", + driver: fakeDriver({ succeeded: true }), + }); + expect(result.passed).toBe(true); + expect(result.error).toBeUndefined(); + }); + + test("RunEvalOptions.timeoutMs applies when the def has none", async () => { + const def = defineEval({ + async test() { + await new Promise(() => {}); + }, + }); + const result = await runEval(def, { + id: "runner-timeout", + driver: fakeDriver({}), + timeoutMs: 20, + }); + expect(result.passed).toBe(false); + expect(result.error).toBe("eval timed out after 20ms"); + }); + + test("def.timeoutMs overrides the runner-level default", async () => { + const def = defineEval({ + timeoutMs: 15, + async test() { + await new Promise(() => {}); + }, + }); + const result = await runEval(def, { + id: "per-eval-wins", + driver: fakeDriver({}), + timeoutMs: 5000, + }); + expect(result.error).toBe("eval timed out after 15ms"); + }); }); diff --git a/packages/appkit/src/evals/types.ts b/packages/appkit/src/evals/types.ts index 03860fd8..e65b7fc3 100644 --- a/packages/appkit/src/evals/types.ts +++ b/packages/appkit/src/evals/types.ts @@ -56,6 +56,8 @@ export interface DriveResult { reply: string; /** Names of tools the agent called during the turn. */ toolCalls: string[]; + /** Tool calls with their parsed arguments, in call order. */ + toolCallDetails: Array<{ name: string; args: Record }>; /** Whether the turn completed without an agent/stream error. */ succeeded: boolean; /** Thread/session id, when the driver exposes one. */ @@ -107,6 +109,15 @@ export interface TestContext { succeeded(): AssertionHandle; /** Assert a tool was called during the run (gate by default). */ calledTool(name: string): AssertionHandle; + /** + * Assert a tool was called with arguments that deep-contain `expected`: every + * key in `expected` must equal the actual argument (recursively for nested + * objects), so extra arguments are ignored. Gate by default. + */ + calledToolWith( + name: string, + expected: Record, + ): AssertionHandle; /** Assert a value against a matcher, e.g. `t.check(t.reply, includes("Sunny"))`. */ check(value: string, matcher: Matcher): AssertionHandle; /** diff --git a/packages/shared/src/cli/commands/agent/eval.ts b/packages/shared/src/cli/commands/agent/eval.ts index 3261d51c..dbba2cc1 100644 --- a/packages/shared/src/cli/commands/agent/eval.ts +++ b/packages/shared/src/cli/commands/agent/eval.ts @@ -1,4 +1,5 @@ -import { Command } from "commander"; +import fs from "node:fs"; +import { Command, Option } from "commander"; interface EvalRunSummary { results: unknown[]; @@ -25,12 +26,16 @@ interface EvalRunner { rootDir?: string; baseUrl: string; filter?: string; + tags?: string[]; strict?: boolean; headers?: Record; mlflow?: { host: string; token: string; experimentId: string }; judge?: { host: string; token: string; model: string }; workspaceClient?: unknown; warehouseId?: string; + maxConcurrency?: number; + timeoutMs?: number; + retries?: number; onEvent?: (event: EvalProgress) => void; }): Promise; resolveDatabricksAuth(opts: { @@ -46,7 +51,9 @@ interface EvalRunner { evalGlyph(result: unknown): string; formatEvalDetail(result: unknown): string[]; formatSummaryLine(results: unknown[]): string; - summarize(results: unknown[]): { allPassed: boolean }; + formatResultsJson(results: unknown[]): string; + formatResultsJUnit(results: unknown[]): string; + summarize(results: unknown[]): { allPassed: boolean; passRate: number }; } /** @@ -82,12 +89,19 @@ interface EvalOptions { strict?: boolean; root?: string; header?: string[]; + tag?: string[]; profile?: string; databricksHost?: string; databricksToken?: string; experiment?: string; judgeModel?: string; warehouse?: string; + concurrency?: string; + timeout?: string; + retries?: string; + minPassRate?: string; + reporter?: "text" | "json" | "junit"; + output?: string; } async function runAgentEval( @@ -96,14 +110,19 @@ async function runAgentEval( ): Promise { const runner = await loadRunner(); - // Resolve Databricks host + bearer the AppKit-native way: an explicit - // host/token (or DATABRICKS_* env) wins; otherwise the SDK mints an OAuth - // token from the CLI profile — so no hand-set PAT is required. - const auth = await runner.resolveDatabricksAuth({ + // Databricks credentials shared by auth resolution and the workspace client: + // an explicit flag/DATABRICKS_* env wins, else the SDK resolves from the CLI + // profile. + const credentials = { profile: opts.profile ?? process.env.DATABRICKS_CONFIG_PROFILE, host: opts.databricksHost ?? process.env.DATABRICKS_HOST, token: opts.databricksToken ?? process.env.DATABRICKS_TOKEN, - }); + }; + + // Resolve Databricks host + bearer the AppKit-native way: an explicit + // host/token wins; otherwise the SDK mints an OAuth token from the CLI + // profile — so no hand-set PAT is required. + const auth = await runner.resolveDatabricksAuth(credentials); const host = auth?.host; const token = auth?.token; @@ -123,29 +142,59 @@ async function runAgentEval( // Managed-dataset reads: a workspace client (same profile/host/token) + a SQL // warehouse. Only needed by evals that declare `dataset`. const warehouseId = opts.warehouse ?? process.env.DATABRICKS_WAREHOUSE_ID; - const workspaceClient = runner.resolveWorkspaceClient({ - profile: opts.profile ?? process.env.DATABRICKS_CONFIG_PROFILE, - host: opts.databricksHost ?? process.env.DATABRICKS_HOST, - token: opts.databricksToken ?? process.env.DATABRICKS_TOKEN, - }); + const workspaceClient = runner.resolveWorkspaceClient(credentials); + + // Drive up to N evals/rows concurrently (default serial). Ignore junk input. + const parsedConcurrency = opts.concurrency + ? Number.parseInt(opts.concurrency, 10) + : undefined; + const maxConcurrency = + parsedConcurrency && parsedConcurrency > 0 ? parsedConcurrency : undefined; + + // Runner-level default per-eval timeout (ms). A per-eval `timeoutMs` wins. + const parsedTimeout = opts.timeout + ? Number.parseInt(opts.timeout, 10) + : undefined; + const timeoutMs = + parsedTimeout && parsedTimeout > 0 ? parsedTimeout : undefined; + + // Extra attempts for evals that fail on an infra error (turn/timeout). Junk + // or negative input falls back to no retries. + const parsedRetries = opts.retries + ? Number.parseInt(opts.retries, 10) + : undefined; + const retries = + parsedRetries && parsedRetries > 0 ? parsedRetries : undefined; + + // In a machine reporter (json/junit), stdout is reserved for the report (it + // may be piped), so human-facing lines go to stderr and the per-eval live + // streaming is suppressed. Text mode keeps its current stdout behavior. + const reporter = opts.reporter ?? "text"; + const machine = reporter !== "text"; + const info = (msg: string): void => { + if (machine) console.error(msg); + else console.log(msg); + }; // Stream progress as evals run, instead of going silent until the end. const onEvent = (event: EvalProgress): void => { switch (event.type) { case "discovered": - console.log( + info( `Running ${event.total} eval${event.total === 1 ? "" : "s"} against ${opts.url}\n`, ); break; case "run-created": - console.log(`MLflow evaluation run: ${event.runId}\n`); + info(`MLflow evaluation run: ${event.runId}\n`); break; case "start": + if (machine) break; process.stdout.write( `▸ [${event.index + 1}/${event.total}] ${event.id} … `, ); break; case "result": { + if (machine) break; process.stdout.write(`${runner.evalGlyph(event.result)}\n`); for (const line of runner.formatEvalDetail(event.result)) { console.log(line); @@ -159,19 +208,26 @@ async function runAgentEval( rootDir: opts.root, baseUrl: opts.url, filter, + tags: opts.tag, strict: opts.strict, headers: opts.header ? parseHeaders(opts.header) : undefined, mlflow, judge, workspaceClient, warehouseId, + maxConcurrency, + timeoutMs, + retries, onEvent, }); - console.log(`\n${runner.formatSummaryLine(summary.results)}`); + + // The final human summary always shows (stderr for machine reporters so it + // never pollutes the report on stdout/file). + info(`\n${runner.formatSummaryLine(summary.results)}`); if (summary.mlflow) { const { report, finish } = summary.mlflow; - console.log( + info( `MLflow: ${report.written} assessment(s) written` + (report.skipped ? `, ${report.skipped} skipped` : "") + (report.failures.length ? `, ${report.failures.length} failed` : ""), @@ -190,13 +246,42 @@ async function runAgentEval( ); } } else { - console.log( + info( "\nMLflow evaluation run skipped — pass --experiment (or set" + " MLFLOW_EXPERIMENT_ID) plus --profile/--databricks-host to create one.", ); } - if (!runner.summarize(summary.results).allPassed) { + // Machine-readable report: build the string with a pure formatter, then emit + // it to --output or stdout (kept clean of the human noise above). + if (machine) { + const report = + reporter === "json" + ? runner.formatResultsJson(summary.results) + : runner.formatResultsJUnit(summary.results); + if (opts.output) { + fs.writeFileSync(opts.output, `${report}\n`); + info(`Wrote ${reporter} report to ${opts.output}`); + } else { + process.stdout.write(`${report}\n`); + } + } + + const stats = runner.summarize(summary.results); + const minPassRate = opts.minPassRate + ? Number.parseFloat(opts.minPassRate) + : undefined; + if (minPassRate !== undefined && !Number.isNaN(minPassRate)) { + // Threshold mode: gate on the aggregate pass rate rather than requiring + // every eval to pass. + const ok = stats.passRate >= minPassRate; + info( + `Pass rate ${(stats.passRate * 100).toFixed(0)}% (threshold ${( + minPassRate * 100 + ).toFixed(0)}%) — ${ok ? "OK" : "below threshold"}`, + ); + if (!ok) process.exitCode = 1; + } else if (!stats.allPassed) { process.exitCode = 1; } } @@ -219,6 +304,10 @@ export const agentEvalCommand = new Command("eval") "--header ", "Extra request header as 'Key: value' (repeatable)", ) + .option( + "--tag ", + "Only run evals tagged with one of these tags (repeatable)", + ) .option( "--profile ", "Databricks CLI profile to authenticate with via OAuth (default: DATABRICKS_CONFIG_PROFILE)", @@ -243,4 +332,32 @@ export const agentEvalCommand = new Command("eval") "--judge-model ", "Databricks serving endpoint to use as the LLM judge for t.judge.* (default: APPKIT_JUDGE_MODEL)", ) + .option( + "--concurrency ", + "Max evals/dataset rows to drive concurrently (default: 1, serial)", + ) + .option( + "--timeout ", + "Default per-eval timeout in ms (a per-eval timeoutMs overrides it)", + ) + .option( + "--retries ", + "Re-run an eval up to N times when it fails on an infra error (turn/timeout); assertion failures are not retried", + ) + .option( + "--min-pass-rate ", + "Gate on aggregate pass rate (0..1) instead of requiring every eval to pass; exit 1 when below", + ) + .addOption( + new Option( + "--reporter ", + "Report format: text (live console), json (dashboards), or junit (CI test reporters)", + ) + .choices(["text", "json", "junit"]) + .default("text"), + ) + .option( + "--output ", + "Write the json/junit report to this file instead of stdout (ignored for text)", + ) .action(runAgentEval);