UNPKG

@mastra/core

Version:
1,445 lines (1,444 loc) 82.5 kB
import { i as MastraError } from "./error-MjDSls8S.js"; import { a as resolveObservabilityContext } from "./observability-Cz-X7NF_.js"; import { f as EntityType } from "./utils-DxsDNzD2.js"; import { a as RequestContext } from "./request-context-p_Tq-4EM.js"; import { t as deepEqual } from "./deep-equal-D_JPy4kj.js"; import { b as isSupportedLanguageModel } from "./trip-wire-csEv7lz7.js"; import { Jt as ScorerRunError, Kt as validateAndSaveScore } from "./agent-Dj30gJa3.js"; import { mn as extractTrajectoryFromTrace, pn as extractTrajectory } from "./constants-BfpAlX25.js"; import { isZodType } from "@mastra/schema-compat"; import { zodToJsonSchema } from "@mastra/schema-compat/zod-to-json"; //#region src/datasets/experiment/events.ts var ExperimentEventDispatcher = class { abortController = new AbortController(); #observer; #experimentId; #sequence = 0; #tail = Promise.resolve(); #failure; get failure() { return this.#failure; } constructor(experimentId, observer) { this.#experimentId = experimentId; this.#observer = observer; } emit(input) { const event = { ...input, version: 1, sequence: ++this.#sequence, timestamp: (/* @__PURE__ */ new Date()).toISOString() }; const delivery = this.#tail.then(async () => { if (this.#failure) throw this.#failure; try { await this.#observer(event); } catch (error) { this.#failure = new MastraError({ id: "EXPERIMENT_EVENT_OBSERVER_FAILED", domain: "EVAL", category: "USER", details: { experimentId: this.#experimentId, eventType: event.type, eventSequence: event.sequence }, text: `Experiment event observer failed while handling "${event.type}".` }, error); this.abortController.abort(this.#failure); throw this.#failure; } }); this.#tail = delivery.catch(() => {}); return delivery; } }; function toExperimentJsonValue(value, seen = /* @__PURE__ */ new WeakSet()) { if (value === null || typeof value === "string" || typeof value === "boolean") return value; if (typeof value === "number") return Number.isFinite(value) ? value : null; if (typeof value === "bigint") return value.toString(); if (typeof value === "undefined" || typeof value === "function" || typeof value === "symbol") return null; if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value.toISOString(); if (value instanceof Error) return { name: value.name, message: value.message }; if (typeof value !== "object") return String(value); if (seen.has(value)) return null; seen.add(value); if (Array.isArray(value)) { const result = value.map((entry) => toExperimentJsonValue(entry, seen)); seen.delete(value); return result; } const result = {}; for (const [key, entry] of Object.entries(value)) result[key] = toExperimentJsonValue(entry, seen); seen.delete(value); return result; } function createItemCompletedEvent(base, itemIndex, result, traceId) { return { ...base, type: "experiment.item.completed", itemIndex, itemId: result.itemId, itemVersion: result.itemVersion, status: result.error ? "failed" : "succeeded", input: toExperimentJsonValue(result.input), output: toExperimentJsonValue(result.output), groundTruth: toExperimentJsonValue(result.groundTruth), error: toExperimentJsonValue(result.error), persistenceError: toExperimentJsonValue(result.persistenceError ?? null), scores: toExperimentJsonValue(result.scores), toolMockReport: toExperimentJsonValue(result.toolMockReport ?? null), retryCount: result.retryCount, startedAt: result.startedAt.toISOString(), completedAt: result.completedAt.toISOString(), traceId }; } //#endregion //#region src/datasets/experiment/tool-mocks.ts /** Deterministic failure codes surfaced via `ExecutionResult.error.code`. */ const TOOL_MOCK_MISMATCH = "TOOL_MOCK_MISMATCH"; const TOOL_MOCK_EXHAUSTED = "TOOL_MOCK_EXHAUSTED"; const TOOL_MOCK_NOT_DECLARED = "TOOL_MOCK_NOT_DECLARED"; /** * Per-item mock matcher. Built fresh for each item run; consumption is tracked * in local state so repeated `(toolName, args)` mocks are served top-to-bottom. * * Tool execution must be forced sequential while an item has mocks so that * ordered consumption is deterministic (the matcher itself is order-sensitive). */ var ToolMockMatcher = class { unmockedToolPolicy; #entries; #served = []; #liveCalls = []; #failure; constructor(mocks, unmockedToolPolicy = "allow") { this.unmockedToolPolicy = unmockedToolPolicy; this.#entries = (mocks ?? []).map((mock, mockIndex) => ({ mockIndex, toolName: mock.toolName, args: mock.args, output: mock.output, matchArgs: mock.matchArgs ?? "strict", consumed: false })); } /** True when the item declares at least one mock (tool execution should run sequentially). */ get hasMocks() { return this.#entries.length > 0; } /** * Resolve a single tool call: * - no mock for this tool → `live` * - unconsumed mock whose args match (deep-equal for `strict`, always for * `ignore`) → `serve` * - tool is mocked but no unconsumed entry matches → `fail` * (`TOOL_MOCK_EXHAUSTED` if args matched but all consumed, else `TOOL_MOCK_MISMATCH`) */ resolve(toolName, args) { if (this.#failure) return { kind: "fail", code: this.#failure.code }; const candidates = this.#entries.filter((entry) => entry.toolName === toolName); if (candidates.length === 0) { if (this.unmockedToolPolicy === "deny") { this.#failure = { code: TOOL_MOCK_NOT_DECLARED, toolName, args }; return { kind: "fail", code: TOOL_MOCK_NOT_DECLARED }; } this.#liveCalls.push({ toolName, args }); return { kind: "live" }; } const argsMatch = (entry) => entry.matchArgs === "ignore" || deepEqual(entry.args, args); const next = candidates.find((entry) => !entry.consumed && argsMatch(entry)); if (next) { next.consumed = true; this.#served.push({ mockIndex: next.mockIndex, toolName, args }); return { kind: "serve", output: next.output }; } const code = candidates.some((entry) => argsMatch(entry)) ? TOOL_MOCK_EXHAUSTED : TOOL_MOCK_MISMATCH; this.#failure ??= { code, toolName, args }; return { kind: "fail", code }; } /** Build the diagnostic report for this item run. */ report() { const unconsumed = this.#entries.filter((entry) => !entry.consumed).map((entry) => ({ mockIndex: entry.mockIndex, toolName: entry.toolName, args: entry.args })); return { served: this.#served, unconsumed, liveCalls: this.#liveCalls, ...this.#failure ? { failure: this.#failure } : {} }; } }; //#endregion //#region src/datasets/experiment/executor.ts /** * Execute a dataset item against a scorer (LLM-as-judge calibration). * item.input should contain exactly what the scorer expects - direct passthrough. * For calibration: item.input = { input, output, groundTruth } (user structures it) */ async function executeScorer(scorer, item) { try { const result = await scorer.run(item.input); const score = typeof result.score === "number" && !isNaN(result.score) ? result.score : null; if (score === null && result.score !== void 0) console.warn(`Scorer ${scorer.id} returned invalid score: ${result.score}`); return { output: { score, reason: typeof result.reason === "string" ? result.reason : null }, error: null, traceId: null }; } catch (error) { return { output: null, error: { message: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : void 0 }, traceId: null }; } } /** Maximum number of suspend/resume cycles to prevent infinite loops */ const MAX_RESUME_CYCLES = 10; /** * Execute a dataset item against a target (agent, workflow, scorer, processor). * Phase 2: agent/workflow. Phase 4: scorer. Processor deferred. */ async function executeTarget(target, targetType, item, options) { try { const signal = options?.signal; if (signal?.aborted) throw signal.reason ?? new DOMException("The operation was aborted.", "AbortError"); let executionPromise; switch (targetType) { case "agent": executionPromise = executeAgent(target, item, signal, options?.requestContext, options?.experimentId, options?.versions, options?.toolMocks, options?.unmockedToolPolicy); break; case "workflow": executionPromise = executeWorkflow(target, item, options?.requestContext); break; case "scorer": executionPromise = executeScorer(target, item); break; case "processor": throw new Error(`Target type '${targetType}' not yet supported.`); default: throw new Error(`Unknown target type: ${targetType}`); } if (signal) return await raceWithSignal(executionPromise, signal); return await executionPromise; } catch (error) { return { output: null, error: { message: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : void 0 }, traceId: null }; } } /** * Race a promise against an AbortSignal. Rejects with the signal's reason when aborted. */ function raceWithSignal(promise, signal) { if (signal.aborted) return Promise.reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError")); return new Promise((resolve, reject) => { const onAbort = () => { reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError")); }; signal.addEventListener("abort", onAbort, { once: true }); promise.then((value) => { signal.removeEventListener("abort", onAbort); resolve(value); }, (err) => { signal.removeEventListener("abort", onAbort); reject(err); }); }); } /** * Execute a dataset item against an agent. * Uses generate() for both v1 and v2 models. */ async function executeAgent(agent, item, signal, requestContext, experimentId, versions, toolMocks, unmockedToolPolicy) { const model = await agent.getModel(); const input = item.input; const reqCtx = requestContext ? new RequestContext(Object.entries(requestContext)) : void 0; const tracingOptions = experimentId ? { metadata: { experimentId } } : void 0; const matcher = new ToolMockMatcher(toolMocks, unmockedToolPolicy); const shouldInterceptTools = matcher.hasMocks || matcher.unmockedToolPolicy === "deny"; const mockAbort = shouldInterceptTools ? new AbortController() : void 0; const mockHooks = shouldInterceptTools ? buildToolMockHooks(agent, matcher, mockAbort) : void 0; const generateSignal = mockAbort && signal ? AbortSignal.any([signal, mockAbort.signal]) : mockAbort?.signal ?? signal; const mockConcurrency = shouldInterceptTools ? { toolCallConcurrency: 1 } : void 0; let rawResult; try { rawResult = isSupportedLanguageModel(model) ? await agent.generate(input, { scorers: {}, returnScorerData: true, abortSignal: generateSignal, ...reqCtx ? { requestContext: reqCtx } : {}, ...tracingOptions ? { tracingOptions } : {}, ...versions ? { versions } : {}, ...mockHooks ? { hooks: mockHooks } : {}, ...mockConcurrency ?? {} }) : await agent.generateLegacy(input, { scorers: {}, returnScorerData: true, abortSignal: generateSignal, ...reqCtx ? { requestContext: reqCtx } : {}, ...tracingOptions ? { tracingOptions } : {}, ...mockHooks ? { hooks: mockHooks } : {}, ...mockConcurrency ?? {} }); } catch (error) { const mockReport = shouldInterceptTools ? matcher.report() : void 0; if (mockReport?.failure) return toolMockFailureResult(mockReport, null); throw error; } const result = rawResult; const traceId = result.traceId ?? null; const scoringData = result.scoringData; const toolMockReport = shouldInterceptTools ? matcher.report() : void 0; if (toolMockReport?.failure) return toolMockFailureResult(toolMockReport, traceId); return { output: { text: result.text, object: result.object, toolCalls: result.toolCalls, toolResults: result.toolResults, sources: result.sources, files: result.files, usage: result.usage, reasoningText: result.reasoningText, traceId, error: result.error ?? null }, error: null, traceId, scorerInput: scoringData?.input, scorerOutput: scoringData?.output, ...toolMockReport ? { toolMockReport } : {} }; } /** Build the deterministic, non-retryable failure result for a mis-called mock. */ function toolMockFailureResult(report, traceId) { const failure = report.failure; return { output: null, error: { message: failure.code === "TOOL_MOCK_NOT_DECLARED" ? `Tool "${failure.toolName}" was called without a declared mock (${failure.code}).` : `Mocked tool "${failure.toolName}" was called with arguments that did not match an available mock (${failure.code}).`, code: failure.code }, traceId, toolMockReport: report }; } /** * Compose item-level tool mocks with the agent's configured tool hooks into a * single set of run-level hooks. * * Composition order (per spec): * 1. User `beforeToolCall` (if `{ proceed: false }`, short-circuit — the mock is * left unconsumed and reported as such; user `afterToolCall` is NOT called, * matching the agent's own short-circuit behavior). * 2. Mock matcher — `serve` returns the mocked output; `fail` aborts the run so * the model cannot call any further (possibly unmocked, side-effecting) tools * live; `live` falls through to the real tool. * 3. User `afterToolCall` runs for served mocks (the agent skips its own on * short-circuit, so it is invoked here to honor the documented composition). * * Ordered consumption of repeated `(toolName, args)` mocks is deterministic because * the caller forces `toolCallConcurrency: 1` when mocks exist, so tool calls arrive * (and consume) in the provider's call order — no mutex needed. */ function buildToolMockHooks(agent, matcher, mockAbort) { const userHooks = agent.getConfiguredToolHooks(); return { beforeToolCall: async (context) => { const userResult = await userHooks?.beforeToolCall?.(context); if (userResult?.proceed === false) return userResult; const resolution = matcher.resolve(context.toolName, context.input); if (resolution.kind === "serve") { await userHooks?.afterToolCall?.({ ...context, output: resolution.output }); return { proceed: false, output: resolution.output }; } if (resolution.kind === "fail") { mockAbort.abort(/* @__PURE__ */ new Error(`Tool mock failure for "${context.toolName}" (${resolution.code})`)); return { proceed: false, output: { error: resolution.code } }; } }, afterToolCall: userHooks?.afterToolCall }; } /** * Extract resume data from item fields and metadata. * * Checks top-level `resumeSteps`/`resumeData` first (inline data path), * then falls back to `metadata.resumeSteps`/`metadata.resumeData` (storage-backed path). * * Supports two shapes: * 1. Keyed by step ID: `resumeSteps: { "step-id": <payload> }` * Used when the workflow may suspend on multiple steps and each needs distinct data. * 2. Flat payload: `resumeData: <payload>` * Used when the workflow has a single suspended step (auto-detected). */ function extractResumeData(item) { return { perStep: item.resumeSteps !== void 0 ? item.resumeSteps : item.metadata?.resumeSteps, flat: item.resumeData !== void 0 ? item.resumeData : item.metadata?.resumeData }; } /** * Execute a dataset item against a workflow. * Creates a run with scorers disabled to avoid double-scoring. * * When the workflow suspends, checks for resume data in `item.metadata` * (via `resumeSteps` keyed by step ID or `resumeData` for single-step workflows) * and automatically resumes. Loops through multiple suspend/resume cycles up to * MAX_RESUME_CYCLES to support multi-step suspend workflows. * * Mirrors `executeWorkflow` in evals/run so dataset experiments and runEvals * produce the same observability spans and scoring data for workflow targets. */ async function executeWorkflow(workflow, item, requestContext) { const reqCtx = requestContext ? new RequestContext(Object.entries(requestContext)) : void 0; const observabilityContext = resolveObservabilityContext({}); const run = await workflow.createRun({ disableScorers: true }); let result = await run.start({ inputData: item.input, ...reqCtx ? { requestContext: reqCtx } : {}, ...observabilityContext }); const { perStep, flat } = extractResumeData(item); if (perStep !== void 0 || flat !== void 0) { let cycle = 0; while (result.status === "suspended" && cycle < MAX_RESUME_CYCLES) { cycle++; const suspendedPaths = result.suspended ?? []; if (suspendedPaths.length === 0) break; const firstSuspendedStep = suspendedPaths[0]?.[0]; if (!firstSuspendedStep) break; const perStepValue = perStep?.[firstSuspendedStep]; const stepResumeData = perStepValue !== void 0 ? perStepValue : flat; if (stepResumeData === void 0) break; result = await run.resume({ resumeData: stepResumeData, step: firstSuspendedStep, ...reqCtx ? { requestContext: reqCtx } : {}, ...observabilityContext }); } } return handleWorkflowResult(result); } /** * Map a terminal WorkflowResult to an ExecutionResult. * Uses a loose `result: any` parameter because WorkflowResult is heavily generic; * status-narrowing guards below keep accesses safe. */ function handleWorkflowResult(result) { const traceId = result.traceId ?? null; const spanId = result.spanId ?? null; if (result.status === "success") return { output: result.result, error: null, traceId, spanId, stepResults: result.steps, stepExecutionPath: result.stepExecutionPath }; if (result.status === "failed") return { output: null, error: { message: result.error?.message ?? "Workflow failed", stack: result.error?.stack }, traceId, spanId, stepResults: result.steps, stepExecutionPath: result.stepExecutionPath }; if (result.status === "tripwire") return { output: null, error: { message: `Workflow tripwire: ${result.tripwire?.reason ?? "Unknown reason"}` }, traceId, spanId, stepResults: result.steps, stepExecutionPath: result.stepExecutionPath }; if (result.status === "suspended") return { output: result.suspendPayload ?? null, error: { message: "Workflow suspended — provide resume data via item.resumeSteps/item.resumeData (or metadata.resumeSteps/metadata.resumeData) to auto-resume" }, traceId, spanId, stepResults: result.steps, stepExecutionPath: result.stepExecutionPath }; if (result.status === "paused") return { output: null, error: { message: "Workflow paused - not yet supported in dataset experiments" }, traceId, spanId, stepResults: result.steps, stepExecutionPath: result.stepExecutionPath }; return { output: null, error: { message: `Workflow ended with unexpected status: ${result.status}` }, traceId, spanId }; } //#endregion //#region src/datasets/experiment/scorer.ts function toScorerTargetEntityType(targetType) { switch (targetType) { case "agent": return EntityType.AGENT; case "workflow": return EntityType.WORKFLOW_RUN; case "scorer": return EntityType.SCORER; default: return; } } function getItemScorerById(mastra, scorerId) { try { return mastra.getScorerById(scorerId) ?? null; } catch { return null; } } /** * Resolve scorers from mixed array of instances and string IDs. * String IDs are looked up from Mastra's scorer registry. */ function resolveScorers(mastra, scorers) { if (!scorers || scorers.length === 0) return []; return scorers.map((scorer) => { if (typeof scorer === "string") { const resolved = mastra.getScorerById(scorer); if (!resolved) { console.warn(`Scorer not found: ${scorer}`); return null; } return resolved; } return scorer; }).filter((s) => s !== null); } const EXPERIMENT_ITEM_SCORER_NOT_FOUND = "EXPERIMENT_ITEM_SCORER_NOT_FOUND"; /** * Create a run-scoped resolver for item scorer IDs. Resolutions, including misses, * are cached so concurrent items hydrate a stored scorer at most once per run. */ function createItemScorerResolver(mastra) { const resolutionCache = /* @__PURE__ */ new Map(); const resolveById = (scorerId) => { const cached = resolutionCache.get(scorerId); if (cached) return cached; const resolution = (async () => { let scorer = getItemScorerById(mastra, scorerId); if (scorer) return scorer; const editor = mastra.getEditor?.(); if (editor) { try { await editor.scorer.getById(scorerId); } catch {} scorer = getItemScorerById(mastra, scorerId); } return scorer; })(); resolutionCache.set(scorerId, resolution); return resolution; }; return async (scorerIds) => { const uniqueIds = [...new Set(scorerIds)]; const resolved = await Promise.all(uniqueIds.map(async (id) => ({ id, scorer: await resolveById(id) }))); return { scorers: resolved.flatMap(({ scorer }) => scorer ? [scorer] : []), missingIds: resolved.flatMap(({ id, scorer }) => scorer ? [] : [id]) }; }; } /** * Attempt to extract a Trajectory from the observability trace store. * Falls back to undefined if storage is unavailable or the trace has no spans. */ async function extractTrajectoryFromStorage(storage, traceId) { if (!storage || !traceId) return void 0; try { const observabilityStore = await storage.getStore("observability"); if (!observabilityStore) return void 0; const trace = await observabilityStore.getTrace({ traceId }); if (!trace?.spans?.length) return void 0; return extractTrajectoryFromTrace(trace.spans); } catch { return; } } /** * Run all scorers for a single item result. * Errors are isolated per scorer - one failing scorer doesn't affect others. * Trajectory scorers (scorer.type === 'trajectory') receive a pre-extracted * Trajectory as their output, mirroring the dispatch runEvals performs. * * `persistScores: false` suppresses score writes while leaving `storage` usable * for reads. The two are deliberately separate parameters: `storage` is also the * source for trajectory extraction, so nulling it to stop writes would silently * downgrade trajectory scorers to the raw-message fallback. */ async function runScorersForItem(scorers, item, output, storage, runId, targetType, targetId, itemId, scorerInput, scorerOutput, traceId, workflowData, persistScores = true) { if (scorers.length === 0) return []; const hasTrajectoryScorer = scorers.some((s) => s.type === "trajectory"); let trajectoryOutput; if (hasTrajectoryScorer) trajectoryOutput = await extractTrajectoryFromStorage(storage, traceId) ?? (scorerOutput ? extractTrajectory(scorerOutput) : { steps: [] }); const targetCorrelationContext = { ...traceId ? { traceId } : {}, entityType: toScorerTargetEntityType(targetType), entityId: targetId, entityName: targetId, experimentId: runId }; return (await Promise.allSettled(scorers.map(async (scorer) => { const { result, promptMetadata } = await runScorerSafe(scorer, item, output, scorerInput, scorerOutput, targetType, traceId, targetCorrelationContext, scorer.type === "trajectory" ? trajectoryOutput : void 0, workflowData, persistScores); if (persistScores && storage && result.error === null && result.score !== null) try { await validateAndSaveScore(storage, { scorerId: scorer.id, score: result.score, reason: result.reason ?? void 0, input: item.input, output, additionalContext: item.metadata, entityType: targetType.toUpperCase(), entityId: itemId, source: "TEST", runId, traceId, scorer: { id: scorer.id, name: scorer.name, description: scorer.description ?? "", hasJudge: !!scorer.judge }, entity: { id: targetId, name: targetId }, ...promptMetadata }); } catch (saveError) { console.warn(`Failed to save score for scorer ${scorer.id}:`, saveError); } return result; }))).map((s, i) => { if (s.status === "fulfilled") return s.value; const scorer = scorers[i]; return { scorerId: scorer.id, scorerName: scorer.name, score: null, reason: null, error: String(s.reason), targetScope: scorer.type === "trajectory" ? "trajectory" : "span" }; }); } function extractScorerRunFields(scoreResult) { if (typeof scoreResult !== "object" || scoreResult === null) return { score: null, reason: null, promptMetadata: {} }; const fields = scoreResult; const str = (key) => typeof fields[key] === "string" ? fields[key] : void 0; const obj = (key) => { const value = fields[key]; return typeof value === "object" && value !== null ? value : void 0; }; return { score: typeof fields.score === "number" ? fields.score : null, reason: typeof fields.reason === "string" ? fields.reason : null, promptMetadata: { generateScorePrompt: str("generateScorePrompt"), generateReasonPrompt: str("generateReasonPrompt"), preprocessStepResult: obj("preprocessStepResult"), preprocessPrompt: str("preprocessPrompt"), analyzeStepResult: obj("analyzeStepResult"), analyzePrompt: str("analyzePrompt") } }; } /** * Run a single scorer safely, catching any errors. * Returns both the ScorerResult and prompt metadata for DB persistence. * When trajectoryOutput is provided the scorer receives it as run.output, * honoring the type: 'trajectory' contract. */ async function runScorerSafe(scorer, item, output, scorerInput, scorerOutput, targetType, targetTraceId, targetCorrelationContext, trajectoryOutput, workflowData, persistScores = true) { try { const effectiveOutput = trajectoryOutput ?? scorerOutput ?? output; const effectiveScope = trajectoryOutput ? "trajectory" : "span"; const targetMetadata = !trajectoryOutput && workflowData && (workflowData.stepResults || workflowData.stepExecutionPath) ? { ...workflowData.stepResults ? { stepResults: workflowData.stepResults } : {}, ...workflowData.stepExecutionPath ? { stepExecutionPath: workflowData.stepExecutionPath } : {} } : void 0; const scoreResult = await scorer.run({ input: scorerInput ?? item.input, output: effectiveOutput, groundTruth: item.groundTruth, scoreSource: "experiment", targetScope: effectiveScope, targetEntityType: toScorerTargetEntityType(targetType), targetTraceId, ...workflowData?.spanId ? { targetSpanId: workflowData.spanId } : {}, ...targetCorrelationContext ? { targetCorrelationContext } : {}, ...targetMetadata ? { targetMetadata } : {}, _internal: { emitObservabilityScore: persistScores } }); if (typeof scoreResult !== "object" || scoreResult === null) return { result: { scorerId: scorer.id, scorerName: scorer.name, score: null, reason: null, error: `Scorer ${scorer.name} (${scorer.id}) returned invalid result: expected object, got ${scoreResult === null ? "null" : typeof scoreResult} (${String(scoreResult)})` }, promptMetadata: {} }; const { score, reason, promptMetadata } = extractScorerRunFields(scoreResult); return { result: { scorerId: scorer.id, scorerName: scorer.name, score, reason, error: null, targetScope: effectiveScope }, promptMetadata }; } catch (error) { if (error instanceof ScorerRunError) { const { score, reason, promptMetadata } = extractScorerRunFields(error.result); return { result: { scorerId: scorer.id, scorerName: scorer.name, score, reason, error: error.message, failedStep: error.failedStep, completedSteps: error.completedSteps, targetScope: trajectoryOutput ? "trajectory" : "span" }, promptMetadata }; } return { result: { scorerId: scorer.id, scorerName: scorer.name, score: null, reason: null, error: error instanceof Error ? error.message : String(error), targetScope: trajectoryOutput ? "trajectory" : "span" }, promptMetadata: {} }; } } /** * Resolve step-scoped scorers from a `Record<stepId, (MastraScorer | string)[]>`. * String IDs are looked up from Mastra's scorer registry; missing IDs are skipped * with a warning (matching `resolveScorers`). */ function resolveStepScorers(mastra, stepsConfig) { if (!stepsConfig) return {}; const resolved = {}; for (const [stepId, scorers] of Object.entries(stepsConfig)) { const stepScorers = resolveScorers(mastra, scorers); if (stepScorers.length > 0) resolved[stepId] = stepScorers; } return resolved; } /** * Run step-scoped scorers for a single workflow item. Mirrors the per-step * dispatch in `runEvals`: each scorer runs against `stepResult.payload` and * `stepResult.output`, with `targetScope: 'span'` and * `targetEntityType: WORKFLOW_STEP`. The returned `ScorerResult` carries the * originating `stepId` so callers can disambiguate per-step results in the * flat `scores` array. Steps whose result is missing or did not succeed * surface as an error `ScorerResult` rather than disappearing silently. * * Errors are isolated per scorer (consistent with `runScorersForItem`); a * failing scorer produces a `ScorerResult` with `error` set, not a throw. */ async function runStepScorersForItem(stepScorers, item, workflowData, storage, runId, targetType, targetId, itemId, traceId, persistScores = true) { const stepIds = Object.keys(stepScorers); if (stepIds.length === 0) return []; const results = []; const stepResults = workflowData?.stepResults; for (const stepId of stepIds) { const scorers = stepScorers[stepId]; const stepResult = stepResults?.[stepId]; if (!stepResult || stepResult.status !== "success" || stepResult.output === void 0) { for (const scorer of scorers) results.push({ scorerId: scorer.id, scorerName: scorer.name, score: null, reason: null, error: `Step "${stepId}" did not produce a successful output (status: ${stepResult?.status ?? "missing"})`, targetScope: "span", stepId }); continue; } const stepInput = stepResult.payload !== void 0 ? stepResult.payload : item.input; const stepOutput = stepResult.output; const targetCorrelationContext = { ...traceId ? { traceId } : {}, entityType: EntityType.WORKFLOW_STEP, entityId: stepId, entityName: stepId, experimentId: runId }; const settled = await Promise.allSettled(scorers.map(async (scorer) => { try { const scoreResult = await scorer.run({ input: stepInput, output: stepOutput, groundTruth: item.groundTruth, scoreSource: "experiment", targetScope: "span", targetEntityType: EntityType.WORKFLOW_STEP, targetTraceId: traceId, ...targetCorrelationContext ? { targetCorrelationContext } : {}, _internal: { emitObservabilityScore: persistScores } }); if (typeof scoreResult !== "object" || scoreResult === null) return { scorerId: scorer.id, scorerName: scorer.name, score: null, reason: null, error: `Scorer ${scorer.name} (${scorer.id}) returned invalid result on step ${stepId}`, targetScope: "span", stepId }; const fields = scoreResult; const score = typeof fields.score === "number" ? fields.score : null; const reason = typeof fields.reason === "string" ? fields.reason : null; if (persistScores && storage && score !== null) try { await validateAndSaveScore(storage, { scorerId: scorer.id, score, reason: reason ?? void 0, input: stepInput, output: stepOutput, additionalContext: { ...item.metadata, stepId }, entityType: "WORKFLOW_STEP", entityId: itemId, source: "TEST", runId, traceId, scorer: { id: scorer.id, name: scorer.name, description: scorer.description ?? "", hasJudge: !!scorer.judge }, entity: { id: targetId, name: targetId } }); } catch (saveError) { console.warn(`Failed to save score for step scorer ${scorer.id} on ${stepId}:`, saveError); } return { scorerId: scorer.id, scorerName: scorer.name, score, reason, error: null, targetScope: "span", stepId }; } catch (error) { if (error instanceof ScorerRunError) { const { score, reason } = extractScorerRunFields(error.result); return { scorerId: scorer.id, scorerName: scorer.name, score, reason, error: error.message, failedStep: error.failedStep, completedSteps: error.completedSteps, targetScope: "span", stepId }; } return { scorerId: scorer.id, scorerName: scorer.name, score: null, reason: null, error: error instanceof Error ? error.message : String(error), targetScope: "span", stepId }; } })); for (let i = 0; i < settled.length; i++) { const s = settled[i]; if (s.status === "fulfilled") results.push(s.value); else { const scorer = scorers[i]; results.push({ scorerId: scorer.id, scorerName: scorer.name, score: null, reason: null, error: String(s.reason), targetScope: "span", stepId }); } } } return results; } //#endregion //#region src/datasets/experiment/analytics/aggregate.ts /** * Compute the arithmetic mean of an array of numbers. * * @param values - Array of numbers to average * @returns Mean value, or 0 if array is empty */ function computeMean(values) { if (values.length === 0) return 0; return values.reduce((acc, val) => acc + val, 0) / values.length; } /** * Compute aggregate statistics for a set of scores. * * Metrics: * - errorRate: proportion of items with null scores (errors) * - passRate: proportion of scored items meeting threshold * - avgScore: mean of non-null scores * * @param scores - Score records from storage * @param passThreshold - Absolute threshold for pass (score >= threshold) * @returns ScorerStats with all computed metrics */ function computeScorerStats(scores, passThreshold = .5) { const totalItems = scores.length; if (totalItems === 0) return { errorRate: 0, errorCount: 0, passRate: 0, passCount: 0, avgScore: 0, scoreCount: 0, totalItems: 0 }; const validScores = []; let errorCount = 0; for (const score of scores) if (score.score === null || score.score === void 0) errorCount++; else validScores.push(score.score); const scoreCount = validScores.length; const errorRate = errorCount / totalItems; const passCount = validScores.filter((s) => s >= passThreshold).length; const passRate = scoreCount > 0 ? passCount / scoreCount : 0; const avgScore = computeMean(validScores); return { errorRate, errorCount, passRate, passCount, avgScore, scoreCount, totalItems }; } /** * Determine if a score delta represents a regression. * * @param delta - Score difference (experiment B - experiment A) * @param threshold - Absolute threshold for regression detection * @param direction - Score direction ('higher-is-better' or 'lower-is-better') * @returns True if delta represents a regression * * @example * // Higher is better (default): negative delta is bad * isRegression(-0.1, 0.05, 'higher-is-better') // true (dropped more than 0.05) * isRegression(-0.01, 0.05, 'higher-is-better') // false (within tolerance) * * // Lower is better: positive delta is bad * isRegression(0.1, 0.05, 'lower-is-better') // true (increased more than 0.05) */ function isRegression(delta, threshold, direction = "higher-is-better") { if (direction === "higher-is-better") return delta < -threshold; else return delta > threshold; } //#endregion //#region src/datasets/experiment/analytics/compare.ts /** * Default threshold when not specified: no tolerance for regression. */ const DEFAULT_THRESHOLD = { value: 0, direction: "higher-is-better" }; /** * Default pass threshold for computing pass rate. */ const DEFAULT_PASS_THRESHOLD = .5; /** * Compare two experiments to detect score regressions. * * @param mastra - Mastra instance for storage access * @param config - Comparison configuration * @returns ComparisonResult with per-scorer and per-item comparisons * * @example * ```typescript * const result = await compareExperiments(mastra, { * experimentIdA: 'baseline-experiment-id', * experimentIdB: 'candidate-experiment-id', * thresholds: { * 'accuracy': { value: 0.05, direction: 'higher-is-better' }, * 'latency': { value: 100, direction: 'lower-is-better' }, * }, * }); * * if (result.hasRegression) { * console.log('Quality regression detected!'); * } * ``` */ async function compareExperiments(mastra, config) { const { experimentIdA, experimentIdB, thresholds = {} } = config; const warnings = []; const storage = mastra.getStorage(); if (!storage) throw new Error("Storage not configured. Configure storage in Mastra instance."); const experimentsStore = await storage.getStore("experiments"); const scoresStore = await storage.getStore("scores"); if (!experimentsStore) throw new Error("ExperimentsStorage not configured."); if (!scoresStore) throw new Error("ScoresStorage not configured."); const [experimentA, experimentB] = await Promise.all([experimentsStore.getExperimentById({ id: experimentIdA }), experimentsStore.getExperimentById({ id: experimentIdB })]); if (!experimentA) throw new Error(`Experiment not found: ${experimentIdA}`); if (!experimentB) throw new Error(`Experiment not found: ${experimentIdB}`); const versionMismatch = experimentA.datasetVersion !== experimentB.datasetVersion; if (versionMismatch) warnings.push(`Experiments have different dataset versions: ${experimentA.datasetVersion} vs ${experimentB.datasetVersion}`); const [resultsA, resultsB] = await Promise.all([experimentsStore.listExperimentResults({ experimentId: experimentIdA, pagination: { page: 0, perPage: false } }), experimentsStore.listExperimentResults({ experimentId: experimentIdB, pagination: { page: 0, perPage: false } })]); const [scoresA, scoresB] = await Promise.all([scoresStore.listScoresByRunId({ runId: experimentIdA, pagination: { page: 0, perPage: false } }), scoresStore.listScoresByRunId({ runId: experimentIdB, pagination: { page: 0, perPage: false } })]); if (resultsA.results.length === 0 && resultsB.results.length === 0) { warnings.push("Both experiments have no results."); return buildEmptyResult(experimentA, experimentB, versionMismatch, warnings); } if (resultsA.results.length === 0) warnings.push("Experiment A has no results."); if (resultsB.results.length === 0) warnings.push("Experiment B has no results."); const itemIdsA = new Set(resultsA.results.map((r) => r.itemId)); const itemIdsB = new Set(resultsB.results.map((r) => r.itemId)); if ([...itemIdsA].filter((id) => itemIdsB.has(id)).length === 0) warnings.push("No overlapping items between experiments."); const scoresMapA = groupScoresByScorerAndItem(scoresA.scores); const scoresMapB = groupScoresByScorerAndItem(scoresB.scores); const allScorerIds = /* @__PURE__ */ new Set([...Object.keys(scoresMapA), ...Object.keys(scoresMapB)]); const scorers = {}; let hasRegression = false; for (const scorerId of allScorerIds) { const scorerScoresA = scoresMapA[scorerId] ?? {}; const scorerScoresB = scoresMapB[scorerId] ?? {}; const scoresArrayA = Object.values(scorerScoresA); const scoresArrayB = Object.values(scorerScoresB); const thresholdConfig = thresholds[scorerId] ?? DEFAULT_THRESHOLD; const threshold = thresholdConfig.value; const direction = thresholdConfig.direction ?? "higher-is-better"; const statsA = computeScorerStats(scoresArrayA, DEFAULT_PASS_THRESHOLD); const statsB = computeScorerStats(scoresArrayB, DEFAULT_PASS_THRESHOLD); const delta = statsB.avgScore - statsA.avgScore; const regressed = isRegression(delta, threshold, direction); if (regressed) hasRegression = true; scorers[scorerId] = { statsA, statsB, delta, regressed, threshold }; } const allItemIds = /* @__PURE__ */ new Set([...itemIdsA, ...itemIdsB]); const items = []; for (const itemId of allItemIds) { const inBothExperiments = itemIdsA.has(itemId) && itemIdsB.has(itemId); const itemScoresA = {}; const itemScoresB = {}; for (const scorerId of allScorerIds) { const scoreA = scoresMapA[scorerId]?.[itemId]; const scoreB = scoresMapB[scorerId]?.[itemId]; itemScoresA[scorerId] = scoreA?.score ?? null; itemScoresB[scorerId] = scoreB?.score ?? null; } items.push({ itemId, inBothExperiments, scoresA: itemScoresA, scoresB: itemScoresB }); } return { experimentA: { id: experimentA.id, datasetVersion: experimentA.datasetVersion }, experimentB: { id: experimentB.id, datasetVersion: experimentB.datasetVersion }, versionMismatch, hasRegression, scorers, items, warnings }; } /** * Group scores by scorer ID, then by item ID. */ function groupScoresByScorerAndItem(scores) { const result = {}; for (const score of scores) { const scorerId = score.scorerId; const itemId = score.entityId; if (!result[scorerId]) result[scorerId] = {}; result[scorerId][itemId] = score; } return result; } /** * Build an empty comparison result for edge cases. */ function buildEmptyResult(experimentA, experimentB, versionMismatch, warnings) { return { experimentA: { id: experimentA.id, datasetVersion: experimentA.datasetVersion }, experimentB: { id: experimentB.id, datasetVersion: experimentB.datasetVersion }, versionMismatch, hasRegression: false, scorers: {}, items: [], warnings }; } //#endregion //#region src/datasets/experiment/index.ts /** * Run a dataset experiment against a target with optional scoring. * * Executes all items in the dataset concurrently (up to maxConcurrency) against * the specified target (agent or workflow). Optionally applies scorers to each * result and persists both results and scores to storage. * * @param mastra - Mastra instance for storage and target resolution * @param config - Experiment configuration * @returns ExperimentSummary with results and scores * * @example * ```typescript * const summary = await runExperiment(mastra, { * datasetId: 'my-dataset', * targetType: 'agent', * targetId: 'my-agent', * scorers: [accuracyScorer, latencyScorer], * maxConcurrency: 10, * }); * console.log(`${summary.succeededCount}/${summary.totalItems} succeeded`); * ``` */ async function runExperiment(mastra, config) { const { datasetId, targetType, targetId, scorers: scorerInput, version, maxConcurrency = 5, signal, onEvent, itemTimeout, maxRetries = 0, experimentId: providedExperimentId, name, description, metadata, requestContext: globalRequestContext, agentVersion, versions, persistence } = config; const persistExperiments = persistence?.experiments !== "none"; const persistScores = persistence?.scores !== "none"; const startedAt = /* @__PURE__ */ new Date(); const experimentId = providedExperimentId ?? crypto.randomUUID(); const eventDispatcher = onEvent ? new ExperimentEventDispatcher(experimentId, onEvent) : void 0; const executionSignal = eventDispatcher ? signal ? AbortSignal.any([signal, eventDispatcher.abortController.signal]) : eventDispatcher.abortController.signal : signal; const eventTarget = { type: targetType ?? "task", id: targetId ?? "inline" }; const storage = mastra.getStorage(); const datasetsStore = await storage?.getStore("datasets"); const experimentsStore = persistExperiments ? await storage?.getStore("experiments") : void 0; const markFailedForObserverError = async (counts) => { if (!experimentsStore) return; try { await experimentsStore.updateExperiment({ id: experimentId, status: "failed", ...counts }); } catch {} }; const markFailedOnSetupError = async (err) => { if (providedExperimentId && experimentsStore) try { await experimentsStore.updateExperiment({ id: experimentId, status: "failed", completedAt: /* @__PURE__ */ new Date() }); } catch (updateErr) { mastra.getLogger()?.error(`Failed to mark experiment ${experimentId} as failed: ${updateErr}`); } throw err; }; let items; let datasetVersion; let datasetRecord; try { if (config.data) { items = (typeof config.data === "function" ? await config.data() : config.data).map((dataItem) => { return { id: dataItem.id ?? crypto.randomUUID(), datasetVersion: null, input: dataItem.input, groundTruth: dataItem.groundTruth, requestContext: dataItem.requestContext, metadata: dataItem.metadata, resumeSteps: dataItem.resumeSteps, resumeData: dataItem.resumeData, toolMocks: dataItem.toolMocks, unmockedToolPolicy: dataItem.unmockedToolPolicy, scorerIds: dataItem.scorerIds }; }); datasetVersion = null; } else if (datasetId) { if (!datasetsStore) throw new Error("DatasetsStorage not configured. Configure storage in Mastra instance."); datasetRecord = await datasetsStore.getDatasetById({ id: datasetId, filters: config.filters }); if (!datasetRecord) throw new MastraError({ id: "DATASET_NOT_FOUND", text: `Dataset not found: ${datasetId}`, domain: "STORAGE", category: "USER" }); datasetVersion = version ?? datasetRecord.version; const versionItems = await datasetsStore.getItemsByVersion({ datasetId, version: datasetVersion }); if (versionItems.length === 0) throw new MastraError({ id: "EXPERIMENT_NO_ITEMS", text: `No items in dataset ${datasetId} at version ${datasetVersion}`, domain: "STORAGE", category: "USER" }); items = versionItems.map((v) => ({ id: v.id, datasetVersion: v.datasetVersion, input: v.input, groundTruth: v.groundTruth, requestContext: v.requestContext, metadata: v.metadata, toolMocks: v.toolMocks, unmockedToolPolicy: v.unmockedToolPolicy, scorerIds: v.scorerIds })); } else throw new Error("No data source: provide datasetId or data"); } catch (err) { await markFailedOnSetupError(err); throw err; } let execFn; try { if (config.task) { const taskFn = config.task; execFn = async (item, itemSignal) => { try { return { output: await taskFn({ input: item.input, mastra, groundTruth: item.groundTruth, metadata: item.metadata, signal: itemSignal }), error: null, traceId: null }; } catch (err) { return { output: null, error: { message: err instanceof Error ? err.message : String(err), stack: err instanceof Error ? err.stack : void 0 }, traceId: null }; } }; } else if (targetType && targetId) { const resolved = await resolveTarget(mastra, targetType, targetId, agentVersion); if (!resolved) throw new Error(`Target not found: ${targetType}/${targetId}`); const { target } = resolved; execFn = (item, itemSignal) => { const mergedRequestContext = globalRequestContext || item.requestContext ? { ...globalRequestContext, ...item.requestContext } : void 0; return executeTarget(target, targetType, item, { signal: itemSignal, requestContext: mergedRequestContext, experimentId, versions, toolMocks: targetType === "agent" ? item.toolMocks : void 0, unmockedToolPolicy: targetType === "agent" ? item.unmockedToolPolicy ?? config.unmockedToolPolicy ?? "allow" : void 0 }); }; } else throw new Error("No task: provide targetType+targetId or task"); } catch (err) { await markFailedOnSetupError(err); throw err; } const itemsWithToolMocks = items.filter((item) => item.toolMocks?.length).length; if (targetType !== "agent" && itemsWithToolMocks > 0) mastra.getLogger()?.warn(`Experiment target is "${config.task ? "task" : targetType}" but ${itemsWithToolMocks} of ${items.length} dataset items declare toolMocks. Tool mocks only apply to agent targets and will be ignored.`); const hasRunLevelScorers = scorerInput !== void 0; let stepsConfigInput; let flatScorerInput; if (scorerInput !== void 0) if (Array.isArray(scorerInput)) flatScorerInput = scorerInput; else { flatScorerInput = []; if ("agent" in scorerInput && scorerInput.agent) flatScorerInput.push(...scorerInput.agent); if ("workflow" in scorerInput && scorerInput.workflow) flatScorerInput.push(...scorerInput.workflow); if ("trajectory" in scorerInput && scorerInput.trajectory) flatScorerInput.push(...scorerInput.trajectory); if ("steps" in scorerInput && scorerInput.steps) stepsConfigInput = scorerInput.steps; } if (flatScorerInput?.length) { const seen = /* @__PURE__ */ new Set(); flatScorerInput = flatScorerInput.filter((entry) => { if (typeof entry !== "string") return true; if (seen.has(entry)) return false; seen.add(entry); return true; }); } const runLevelScorers = hasRunLevelScorers ? resolveScorers(mastra, flatScorerInput) : []; const runLevelStepScorers = hasRunLevelScorers ? resolveStepScorers(mastra, stepsConfigInput) : {}; const resolveItemScorers = createItemScorerResolver(mastra); const datasetScorers = !hasRunLevelScorers && items.some((item) => item.scorerIds === void 0) ? resolveScorers(mastra, [...new Set(datasetRecord?.scorerIds ?? [])]) : []; if (experimentsStore) { if (!providedExperimentId) await experimentsStore.createExperiment({ id: experimentId, name, description, metadata, datasetId: datasetId ?? null, datasetVersion, targetType: targetType ?? "agent", targetId: targetId ?? "inline", totalItems: items.length, agentVersion, organizationId: datasetRecord?.organizationId ?? null, projectId: datasetRecord?.projectId ?? null }); await experimentsStore.updateExperiment({ id: experimentId, status: "running", totalItems: items.length, startedAt }); } if (eventDispatcher) try { await eventDispatcher.emit({ type: "experiment.run.started", experimentId, target: eventTarget, status: "running", datasetId: datasetRecord?.id ?? null, datasetVersion, totalItems: items.length }); } catch (observerError) {