UNPKG

@mastra/core

Version:
1 lines 173 kB
{"version":3,"file":"manager-BQFc3uOZ.cjs","names":["#observer","#experimentId","#failure","#sequence","#tail","MastraError","#entries","#served","#liveCalls","#failure","deepEqual","RequestContext","isSupportedLanguageModel","resolveObservabilityContext","EntityType","extractTrajectoryFromTrace","extractTrajectory","validateAndSaveScore","ScorerRunError","MastraError","#mastra","#scope","#getDatasetsStore","#datasetsStore","MastraError","#getExperimentsStore","#experimentsStore","#assertScope","#ownsChild","#assertExperimentOwnership","#mastra","#getDatasetsStore","#datasetsStore","MastraError","#getExperimentsStore","#experimentsStore","compareExperimentsInternal"],"sources":["../src/datasets/experiment/events.ts","../src/datasets/experiment/tool-mocks.ts","../src/datasets/experiment/executor.ts","../src/datasets/experiment/scorer.ts","../src/datasets/experiment/analytics/aggregate.ts","../src/datasets/experiment/analytics/compare.ts","../src/datasets/experiment/index.ts","../src/datasets/dataset.ts","../src/datasets/manager.ts"],"sourcesContent":["import { MastraError } from '../../error/index.js';\nimport type { TargetType } from '../../storage/types';\nimport type { ItemWithScores } from './types';\n\nexport type ExperimentJsonValue =\n | null\n | boolean\n | number\n | string\n | ExperimentJsonValue[]\n | { [key: string]: ExperimentJsonValue };\n\nexport interface ExperimentEventBase {\n version: 1;\n experimentId: string;\n sequence: number;\n timestamp: string;\n target: {\n type: TargetType | 'task';\n id: string;\n };\n}\n\nexport interface ExperimentRunStartedEvent extends ExperimentEventBase {\n type: 'experiment.run.started';\n status: 'running';\n datasetId: string | null;\n datasetVersion: number | null;\n totalItems: number;\n}\n\nexport interface ExperimentItemCompletedEvent extends ExperimentEventBase {\n type: 'experiment.item.completed';\n itemIndex: number;\n itemId: string;\n itemVersion: number;\n status: 'succeeded' | 'failed';\n input: ExperimentJsonValue;\n output: ExperimentJsonValue;\n groundTruth: ExperimentJsonValue;\n error: ExperimentJsonValue;\n persistenceError: ExperimentJsonValue;\n scores: ExperimentJsonValue;\n toolMockReport: ExperimentJsonValue;\n retryCount: number;\n startedAt: string;\n completedAt: string;\n traceId: string | null;\n}\n\nexport interface ExperimentRunFinishedEvent extends ExperimentEventBase {\n type: 'experiment.run.finished';\n status: 'completed' | 'failed';\n outcome: 'completed' | 'failed' | 'cancelled';\n error: ExperimentJsonValue;\n totalItems: number;\n succeededCount: number;\n failedCount: number;\n skippedCount: number;\n persistenceFailures: number;\n completedWithErrors: boolean;\n startedAt: string;\n completedAt: string;\n}\n\nexport type ExperimentEvent = ExperimentRunStartedEvent | ExperimentItemCompletedEvent | ExperimentRunFinishedEvent;\n\nexport type ExperimentEventObserver = (event: ExperimentEvent) => void | Promise<void>;\n\ntype EventInput = ExperimentEvent extends infer Event\n ? Event extends ExperimentEvent\n ? Omit<Event, 'version' | 'sequence' | 'timestamp'>\n : never\n : never;\n\nexport class ExperimentEventDispatcher {\n readonly abortController = new AbortController();\n readonly #observer: ExperimentEventObserver;\n readonly #experimentId: string;\n #sequence = 0;\n #tail = Promise.resolve();\n #failure: MastraError | undefined;\n\n get failure(): MastraError | undefined {\n return this.#failure;\n }\n\n constructor(experimentId: string, observer: ExperimentEventObserver) {\n this.#experimentId = experimentId;\n this.#observer = observer;\n }\n\n emit(input: EventInput): Promise<void> {\n const event = {\n ...input,\n version: 1,\n sequence: ++this.#sequence,\n timestamp: new Date().toISOString(),\n } as ExperimentEvent;\n\n const delivery = this.#tail.then(async () => {\n if (this.#failure) throw this.#failure;\n\n try {\n await this.#observer(event);\n } catch (error) {\n this.#failure = new MastraError(\n {\n id: 'EXPERIMENT_EVENT_OBSERVER_FAILED',\n domain: 'EVAL',\n category: 'USER',\n details: {\n experimentId: this.#experimentId,\n eventType: event.type,\n eventSequence: event.sequence,\n },\n text: `Experiment event observer failed while handling \"${event.type}\".`,\n },\n error,\n );\n this.abortController.abort(this.#failure);\n throw this.#failure;\n }\n });\n\n this.#tail = delivery.catch(() => {});\n return delivery;\n }\n}\n\nexport function toExperimentJsonValue(value: unknown, seen = new WeakSet<object>()): ExperimentJsonValue {\n if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;\n if (typeof value === 'number') return Number.isFinite(value) ? value : null;\n if (typeof value === 'bigint') return value.toString();\n if (typeof value === 'undefined' || typeof value === 'function' || typeof value === 'symbol') return null;\n if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value.toISOString();\n if (value instanceof Error) {\n return {\n name: value.name,\n message: value.message,\n };\n }\n if (typeof value !== 'object') return String(value);\n if (seen.has(value)) return null;\n\n seen.add(value);\n if (Array.isArray(value)) {\n const result = value.map(entry => toExperimentJsonValue(entry, seen));\n seen.delete(value);\n return result;\n }\n\n const result: Record<string, ExperimentJsonValue> = {};\n for (const [key, entry] of Object.entries(value)) {\n result[key] = toExperimentJsonValue(entry, seen);\n }\n seen.delete(value);\n return result;\n}\n\nexport function createItemCompletedEvent(\n base: Pick<ExperimentEventBase, 'experimentId' | 'target'>,\n itemIndex: number,\n result: ItemWithScores,\n traceId: string | null,\n): EventInput {\n return {\n ...base,\n type: 'experiment.item.completed',\n itemIndex,\n itemId: result.itemId,\n itemVersion: result.itemVersion,\n status: result.error ? 'failed' : 'succeeded',\n input: toExperimentJsonValue(result.input),\n output: toExperimentJsonValue(result.output),\n groundTruth: toExperimentJsonValue(result.groundTruth),\n error: toExperimentJsonValue(result.error),\n persistenceError: toExperimentJsonValue(result.persistenceError ?? null),\n scores: toExperimentJsonValue(result.scores),\n toolMockReport: toExperimentJsonValue(result.toolMockReport ?? null),\n retryCount: result.retryCount,\n startedAt: result.startedAt.toISOString(),\n completedAt: result.completedAt.toISOString(),\n traceId,\n };\n}\n","import { deepEqual } from '../../utils';\n\n/**\n * A single static tool mock authored on a dataset item.\n *\n * v1 is output-only: when matched, `output` is served to the agent in place of\n * executing the real tool. Error mocks are intentionally not supported in v1\n * (the agent `beforeToolCall` hook can only short-circuit with an output).\n *\n * Tool mocks only apply to experiments with `targetType: 'agent'`. They are\n * ignored for `task`, `workflow`, and `scorer` targets (the experiment logs a\n * warning if a dataset carrying `toolMocks` is run against a non-agent target).\n */\n/**\n * How a mock's `args` are matched against the agent's tool call.\n * - `strict` (default): deep-equality on `args` (key order ignored, array order\n * significant, no coercion).\n * - `ignore`: match on `toolName` only; `args` are not compared. Useful for\n * tools whose arguments are noisy or LLM-authored — notably sub-agent\n * delegation calls (`agent-*`), whose `prompt` is free text.\n */\nexport type ToolMockMatchArgs = 'strict' | 'ignore';\n\nexport interface ItemToolMock {\n /** Name of the tool this mock applies to. */\n toolName: string;\n /**\n * Arguments to match against the agent's tool call. Compared with deep\n * equality when `matchArgs` is `strict`; ignored when `matchArgs` is `ignore`.\n */\n args: Record<string, unknown>;\n /** Output served to the agent when this mock is matched and consumed. */\n output: unknown;\n /**\n * Argument matching mode for this mock. Defaults to `strict`.\n *\n * @example\n * // strict (default): served only when args deep-equal the call\n * { toolName: 'getWeather', args: { city: 'Seattle' }, output: { tempF: 52 }, matchArgs: 'strict' }\n * // ignore: served for any args to `getWeather` (tool-name-only match)\n * { toolName: 'getWeather', args: {}, output: { tempF: 52 }, matchArgs: 'ignore' }\n */\n matchArgs?: ToolMockMatchArgs;\n}\n\n/** Deterministic failure codes surfaced via `ExecutionResult.error.code`. */\nexport const TOOL_MOCK_MISMATCH = 'TOOL_MOCK_MISMATCH';\nexport const TOOL_MOCK_EXHAUSTED = 'TOOL_MOCK_EXHAUSTED';\nexport const TOOL_MOCK_NOT_DECLARED = 'TOOL_MOCK_NOT_DECLARED';\n\nexport type UnmockedToolPolicy = 'allow' | 'deny';\n\nexport type ToolMockFailureCode =\n | typeof TOOL_MOCK_MISMATCH\n | typeof TOOL_MOCK_EXHAUSTED\n | typeof TOOL_MOCK_NOT_DECLARED;\n\n/** Diagnostic receipt produced for a single item run. Persisted on experiment results. */\nexport interface ToolMockReport {\n /** Mocks that were matched and served, in consumption order. */\n served: { mockIndex: number; toolName: string; args: unknown }[];\n /** Mocks declared on the item that the agent never consumed (report-only — does NOT fail the item). */\n unconsumed: { mockIndex: number; toolName: string; args: unknown }[];\n /** Unmocked tools that ran live — flags that the item was not fully deterministic. */\n liveCalls: { toolName: string; args: unknown }[];\n /** Present when a mocked tool was mis-called and the item failed. */\n failure?: { code: ToolMockFailureCode; toolName: string; args: unknown };\n}\n\n/** Result of attempting to resolve a single tool call against the item's mocks. */\nexport type ToolMockResolution =\n | { kind: 'serve'; output: unknown }\n | { kind: 'live' }\n | { kind: 'fail'; code: ToolMockFailureCode };\n\ninterface MockEntry {\n mockIndex: number;\n toolName: string;\n args: Record<string, unknown>;\n output: unknown;\n matchArgs: ToolMockMatchArgs;\n consumed: boolean;\n}\n\n/**\n * Per-item mock matcher. Built fresh for each item run; consumption is tracked\n * in local state so repeated `(toolName, args)` mocks are served top-to-bottom.\n *\n * Tool execution must be forced sequential while an item has mocks so that\n * ordered consumption is deterministic (the matcher itself is order-sensitive).\n */\nexport class ToolMockMatcher {\n readonly #entries: MockEntry[];\n readonly #served: ToolMockReport['served'] = [];\n readonly #liveCalls: ToolMockReport['liveCalls'] = [];\n #failure: ToolMockReport['failure'];\n\n constructor(\n mocks: ItemToolMock[] | undefined,\n readonly unmockedToolPolicy: UnmockedToolPolicy = 'allow',\n ) {\n this.#entries = (mocks ?? []).map((mock, mockIndex) => ({\n mockIndex,\n toolName: mock.toolName,\n args: mock.args,\n output: mock.output,\n matchArgs: mock.matchArgs ?? 'strict',\n consumed: false,\n }));\n }\n\n /** True when the item declares at least one mock (tool execution should run sequentially). */\n get hasMocks(): boolean {\n return this.#entries.length > 0;\n }\n\n /**\n * Resolve a single tool call:\n * - no mock for this tool → `live`\n * - unconsumed mock whose args match (deep-equal for `strict`, always for\n * `ignore`) → `serve`\n * - tool is mocked but no unconsumed entry matches → `fail`\n * (`TOOL_MOCK_EXHAUSTED` if args matched but all consumed, else `TOOL_MOCK_MISMATCH`)\n */\n resolve(toolName: string, args: unknown): ToolMockResolution {\n // Once any mock has failed, the item is already doomed and being aborted.\n // Fail every subsequent resolution so no further tool runs live/serves\n // during the abort-propagation race.\n if (this.#failure) {\n return { kind: 'fail', code: this.#failure.code };\n }\n\n const candidates = this.#entries.filter(entry => entry.toolName === toolName);\n\n if (candidates.length === 0) {\n if (this.unmockedToolPolicy === 'deny') {\n this.#failure = { code: TOOL_MOCK_NOT_DECLARED, toolName, args };\n return { kind: 'fail', code: TOOL_MOCK_NOT_DECLARED };\n }\n this.#liveCalls.push({ toolName, args });\n return { kind: 'live' };\n }\n\n const argsMatch = (entry: MockEntry): boolean => entry.matchArgs === 'ignore' || deepEqual(entry.args, args);\n\n const next = candidates.find(entry => !entry.consumed && argsMatch(entry));\n if (next) {\n next.consumed = true;\n this.#served.push({ mockIndex: next.mockIndex, toolName, args });\n return { kind: 'serve', output: next.output };\n }\n\n const argsMatchedButConsumed = candidates.some(entry => argsMatch(entry));\n const code: ToolMockFailureCode = argsMatchedButConsumed ? TOOL_MOCK_EXHAUSTED : TOOL_MOCK_MISMATCH;\n // Record only the first failure — the item fails on it and stops.\n this.#failure ??= { code, toolName, args };\n return { kind: 'fail', code };\n }\n\n /** Build the diagnostic report for this item run. */\n report(): ToolMockReport {\n const unconsumed = this.#entries\n .filter(entry => !entry.consumed)\n .map(entry => ({ mockIndex: entry.mockIndex, toolName: entry.toolName, args: entry.args }));\n\n return {\n served: this.#served,\n unconsumed,\n liveCalls: this.#liveCalls,\n ...(this.#failure ? { failure: this.#failure } : {}),\n };\n }\n}\n","import type { Agent } from '../../agent';\nimport { isSupportedLanguageModel } from '../../agent';\nimport type { MessageListInput } from '../../agent/message-list';\nimport type { MastraScorer } from '../../evals/base';\nimport type { ScorerRunInputForAgent, ScorerRunOutputForAgent } from '../../evals/types';\nimport type { ScoringData } from '../../llm/model/base.types';\nimport type { VersionOverrides } from '../../mastra/types';\nimport { resolveObservabilityContext } from '../../observability';\nimport { RequestContext } from '../../request-context';\nimport type { TargetType } from '../../storage/types';\nimport type { ToolHooks } from '../../tools/types';\nimport type { StepResult, Workflow } from '../../workflows';\nimport type { ItemToolMock, ToolMockReport, UnmockedToolPolicy } from './tool-mocks';\nimport { ToolMockMatcher } from './tool-mocks';\n\n/**\n * Common fields extracted from both FullOutput (v2/v3) and GenerateTextResult/GenerateObjectResult (v1).\n * Used to type the agent result uniformly without coupling to the full return types.\n */\ninterface AgentGenerateResult {\n text?: string;\n object?: unknown;\n toolCalls?: unknown[];\n toolResults?: unknown[];\n sources?: unknown[];\n files?: unknown[];\n usage?: { promptTokens: number; completionTokens: number; totalTokens: number };\n reasoningText?: string;\n traceId?: string;\n error?: Error;\n scoringData?: ScoringData;\n}\n\n/**\n * Target types supported for dataset execution.\n * Agent and Workflow are Phase 2; scorer and processor are Phase 4.\n */\nexport type Target = Agent | Workflow | MastraScorer<any, any, any, any>;\n\n/**\n * Result from executing a target against a dataset item.\n */\nexport interface ExecutionResult {\n /** Output from the target (null if failed) */\n output: unknown;\n /** Structured error if execution failed */\n error: { message: string; stack?: string; code?: string } | null;\n /** Trace ID from agent/workflow execution (null for scorers or errors) */\n traceId: string | null;\n /** Root span ID from agent/workflow execution (null when not traced) */\n spanId?: string | null;\n /** Structured input for scorers (extracted from agent scoring data) */\n scorerInput?: ScorerRunInputForAgent;\n /** Structured output for scorers (extracted from agent scoring data) */\n scorerOutput?: ScorerRunOutputForAgent;\n /** Per-step results from a workflow run, keyed by step ID */\n stepResults?: Record<string, StepResult<any, any, any, any>>;\n /** Order in which workflow steps actually executed */\n stepExecutionPath?: string[];\n /** Diagnostic receipt for item-level tool mocks (agent targets only) */\n toolMockReport?: ToolMockReport;\n}\n\n/**\n * Execute a dataset item against a scorer (LLM-as-judge calibration).\n * item.input should contain exactly what the scorer expects - direct passthrough.\n * For calibration: item.input = { input, output, groundTruth } (user structures it)\n */\nasync function executeScorer(\n scorer: MastraScorer<any, any, any, any>,\n item: { input: unknown; groundTruth?: unknown },\n): Promise<ExecutionResult> {\n try {\n // Direct passthrough - scorer receives item.input exactly as provided\n // User structures item.input to match scorer's expected shape (e.g., { input, output, groundTruth })\n const result = await scorer.run(item.input as any);\n\n // Validate score is a number\n const score = typeof result.score === 'number' && !isNaN(result.score) ? result.score : null;\n\n if (score === null && result.score !== undefined) {\n console.warn(`Scorer ${scorer.id} returned invalid score: ${result.score}`);\n }\n\n return {\n output: {\n score,\n reason: typeof result.reason === 'string' ? result.reason : null,\n },\n error: null,\n traceId: null, // Scorers don't produce traces\n };\n } catch (error) {\n return {\n output: null,\n error: {\n message: error instanceof Error ? error.message : String(error),\n stack: error instanceof Error ? error.stack : undefined,\n },\n traceId: null,\n };\n }\n}\n\n/** Maximum number of suspend/resume cycles to prevent infinite loops */\nconst MAX_RESUME_CYCLES = 10;\n\n/**\n * Execute a dataset item against a target (agent, workflow, scorer, processor).\n * Phase 2: agent/workflow. Phase 4: scorer. Processor deferred.\n */\nexport async function executeTarget(\n target: Target,\n targetType: TargetType,\n item: {\n input: unknown;\n groundTruth?: unknown;\n metadata?: Record<string, unknown>;\n resumeSteps?: Record<string, unknown>;\n resumeData?: unknown;\n },\n options?: {\n signal?: AbortSignal;\n requestContext?: Record<string, unknown>;\n experimentId?: string;\n versions?: VersionOverrides;\n /** Item-level static tool mocks (agent targets only). */\n toolMocks?: ItemToolMock[];\n /** Handling for agent tool calls not declared in `toolMocks`. */\n unmockedToolPolicy?: UnmockedToolPolicy;\n },\n): Promise<ExecutionResult> {\n try {\n const signal = options?.signal;\n\n // Check if already aborted before starting\n if (signal?.aborted) {\n throw signal.reason ?? new DOMException('The operation was aborted.', 'AbortError');\n }\n\n let executionPromise: Promise<ExecutionResult>;\n switch (targetType) {\n case 'agent':\n executionPromise = executeAgent(\n target as Agent,\n item,\n signal,\n options?.requestContext,\n options?.experimentId,\n options?.versions,\n options?.toolMocks,\n options?.unmockedToolPolicy,\n );\n break;\n case 'workflow':\n executionPromise = executeWorkflow(target as Workflow, item, options?.requestContext);\n break;\n case 'scorer':\n executionPromise = executeScorer(target as MastraScorer<any, any, any, any>, item);\n break;\n case 'processor':\n // Processor targets dropped from roadmap - not a core use case\n throw new Error(`Target type '${targetType}' not yet supported.`);\n default:\n throw new Error(`Unknown target type: ${targetType}`);\n }\n\n // Race execution against signal abort (ensures timeout works even if target ignores signal)\n if (signal) {\n return await raceWithSignal(executionPromise, signal);\n }\n\n return await executionPromise;\n } catch (error) {\n return {\n output: null,\n error: {\n message: error instanceof Error ? error.message : String(error),\n stack: error instanceof Error ? error.stack : undefined,\n },\n traceId: null,\n };\n }\n}\n\n/**\n * Race a promise against an AbortSignal. Rejects with the signal's reason when aborted.\n */\nfunction raceWithSignal<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {\n if (signal.aborted) {\n return Promise.reject(signal.reason ?? new DOMException('The operation was aborted.', 'AbortError'));\n }\n\n return new Promise<T>((resolve, reject) => {\n const onAbort = () => {\n reject(signal.reason ?? new DOMException('The operation was aborted.', 'AbortError'));\n };\n\n signal.addEventListener('abort', onAbort, { once: true });\n\n promise.then(\n value => {\n signal.removeEventListener('abort', onAbort);\n resolve(value);\n },\n err => {\n signal.removeEventListener('abort', onAbort);\n reject(err);\n },\n );\n });\n}\n\n/**\n * Execute a dataset item against an agent.\n * Uses generate() for both v1 and v2 models.\n */\nasync function executeAgent(\n agent: Agent,\n item: { input: unknown; groundTruth?: unknown },\n signal?: AbortSignal,\n requestContext?: Record<string, unknown>,\n experimentId?: string,\n versions?: VersionOverrides,\n toolMocks?: ItemToolMock[],\n unmockedToolPolicy?: UnmockedToolPolicy,\n): Promise<ExecutionResult> {\n const model = await agent.getModel();\n\n // Both generate() and generateLegacy() return different types (FullOutput vs GenerateTextResult)\n // but share the fields we extract. Cast input to MessageListInput at the boundary.\n const input = item.input as MessageListInput;\n\n const reqCtx: RequestContext | undefined = requestContext\n ? new RequestContext(Object.entries(requestContext))\n : undefined;\n\n // Pass experimentId as tracing metadata so it appears on the AGENT_RUN span\n const tracingOptions = experimentId ? { metadata: { experimentId } } : undefined;\n\n // Build a fresh matcher per item run so ordered consumption is deterministic and\n // not leaked across retries. Compose with the agent's configured hooks.\n const matcher = new ToolMockMatcher(toolMocks, unmockedToolPolicy);\n const shouldInterceptTools = matcher.hasMocks || matcher.unmockedToolPolicy === 'deny';\n\n // When tool calls are intercepted, abort the whole run the instant a tool is\n // mis-called so the model cannot go on to invoke later (possibly side-effecting,\n // unmocked) tools live. The mock-abort signal is combined with the outer signal.\n const mockAbort = shouldInterceptTools ? new AbortController() : undefined;\n const mockHooks = shouldInterceptTools ? buildToolMockHooks(agent, matcher, mockAbort!) : undefined;\n const generateSignal =\n mockAbort && signal ? AbortSignal.any([signal, mockAbort.signal]) : (mockAbort?.signal ?? signal);\n\n // Force sequential tool execution when mocks exist so the provider's tool-call\n // order equals the execution (and consumption) order — deterministic ordered\n // consumption of repeated (toolName, args) mocks. No cost for mock-free runs.\n const mockConcurrency = shouldInterceptTools ? { toolCallConcurrency: 1 } : undefined;\n\n let rawResult: unknown;\n try {\n rawResult = isSupportedLanguageModel(model)\n ? await agent.generate(input, {\n scorers: {},\n returnScorerData: true,\n abortSignal: generateSignal,\n ...(reqCtx ? { requestContext: reqCtx } : {}),\n ...(tracingOptions ? { tracingOptions } : {}),\n ...(versions ? { versions } : {}),\n ...(mockHooks ? { hooks: mockHooks } : {}),\n ...(mockConcurrency ?? {}),\n })\n : await agent.generateLegacy(input, {\n scorers: {},\n returnScorerData: true,\n abortSignal: generateSignal,\n ...(reqCtx ? { requestContext: reqCtx } : {}),\n ...(tracingOptions ? { tracingOptions } : {}),\n ...(mockHooks ? { hooks: mockHooks } : {}),\n ...(mockConcurrency ?? {}),\n });\n } catch (error) {\n // A mock failure aborts the run mid-flight: surface the deterministic coded\n // error instead of the raw abort. Any other error rethrows unchanged.\n const mockReport = shouldInterceptTools ? matcher.report() : undefined;\n if (mockReport?.failure) {\n return toolMockFailureResult(mockReport, null);\n }\n throw error;\n }\n\n // Narrow to the common fields we need — both v1 and v2 results share these\n const result = rawResult as AgentGenerateResult;\n\n const traceId = result.traceId ?? null;\n const scoringData = result.scoringData;\n\n const toolMockReport = shouldInterceptTools ? matcher.report() : undefined;\n\n // Fallback for the race where the model finishes a step before the abort\n // propagates: the matcher still recorded the first failure, so fail the item\n // deterministically with the coded error. The mis-called tool never ran live.\n if (toolMockReport?.failure) {\n return toolMockFailureResult(toolMockReport, traceId);\n }\n\n // Only persist fields relevant to experiment evaluation — drop provider metadata,\n // duplicate messages, steps trace, and other debugging internals\n const trimmedOutput = {\n text: result.text,\n object: result.object,\n toolCalls: result.toolCalls,\n toolResults: result.toolResults,\n sources: result.sources,\n files: result.files,\n usage: result.usage,\n reasoningText: result.reasoningText,\n traceId,\n error: result.error ?? null,\n };\n\n return {\n output: trimmedOutput,\n error: null,\n traceId,\n scorerInput: scoringData?.input,\n scorerOutput: scoringData?.output,\n ...(toolMockReport ? { toolMockReport } : {}),\n };\n}\n\n/** Build the deterministic, non-retryable failure result for a mis-called mock. */\nfunction toolMockFailureResult(report: ToolMockReport, traceId: string | null): ExecutionResult {\n const failure = report.failure!;\n return {\n output: null,\n error: {\n message:\n failure.code === 'TOOL_MOCK_NOT_DECLARED'\n ? `Tool \"${failure.toolName}\" was called without a declared mock (${failure.code}).`\n : `Mocked tool \"${failure.toolName}\" was called with arguments that did not match an available mock (${failure.code}).`,\n code: failure.code,\n },\n traceId,\n toolMockReport: report,\n };\n}\n\n/**\n * Compose item-level tool mocks with the agent's configured tool hooks into a\n * single set of run-level hooks.\n *\n * Composition order (per spec):\n * 1. User `beforeToolCall` (if `{ proceed: false }`, short-circuit — the mock is\n * left unconsumed and reported as such; user `afterToolCall` is NOT called,\n * matching the agent's own short-circuit behavior).\n * 2. Mock matcher — `serve` returns the mocked output; `fail` aborts the run so\n * the model cannot call any further (possibly unmocked, side-effecting) tools\n * live; `live` falls through to the real tool.\n * 3. User `afterToolCall` runs for served mocks (the agent skips its own on\n * short-circuit, so it is invoked here to honor the documented composition).\n *\n * Ordered consumption of repeated `(toolName, args)` mocks is deterministic because\n * the caller forces `toolCallConcurrency: 1` when mocks exist, so tool calls arrive\n * (and consume) in the provider's call order — no mutex needed.\n */\nfunction buildToolMockHooks(agent: Agent, matcher: ToolMockMatcher, mockAbort: AbortController): ToolHooks {\n const userHooks = agent.getConfiguredToolHooks();\n\n return {\n beforeToolCall: async context => {\n // 1. User hook first — a short-circuit leaves the mock unconsumed.\n const userResult = await userHooks?.beforeToolCall?.(context);\n if (userResult?.proceed === false) {\n return userResult;\n }\n\n // 2. Mock matcher.\n const resolution = matcher.resolve(context.toolName, context.input);\n if (resolution.kind === 'serve') {\n await userHooks?.afterToolCall?.({ ...context, output: resolution.output });\n return { proceed: false, output: resolution.output };\n }\n if (resolution.kind === 'fail') {\n // Abort the whole run immediately. The matcher recorded the first failure;\n // the item fails deterministically via the catch path. Short-circuit the\n // tool here too so the mis-called tool never runs live even before the\n // abort propagates.\n mockAbort.abort(new Error(`Tool mock failure for \"${context.toolName}\" (${resolution.code})`));\n return { proceed: false, output: { error: resolution.code } };\n }\n\n // 3. `live` — fall through to the real tool.\n return undefined;\n },\n // Pass the user's afterToolCall through as-is (preserving undefined) so the\n // agent skips a no-op call when the user configured no afterToolCall. Served\n // mocks invoke it manually above, since they short-circuit the real tool.\n afterToolCall: userHooks?.afterToolCall,\n };\n}\n\n/**\n * Extract resume data from item fields and metadata.\n *\n * Checks top-level `resumeSteps`/`resumeData` first (inline data path),\n * then falls back to `metadata.resumeSteps`/`metadata.resumeData` (storage-backed path).\n *\n * Supports two shapes:\n * 1. Keyed by step ID: `resumeSteps: { \"step-id\": <payload> }`\n * Used when the workflow may suspend on multiple steps and each needs distinct data.\n * 2. Flat payload: `resumeData: <payload>`\n * Used when the workflow has a single suspended step (auto-detected).\n */\nfunction extractResumeData(item: {\n metadata?: Record<string, unknown>;\n resumeSteps?: Record<string, unknown>;\n resumeData?: unknown;\n}): {\n perStep?: Record<string, unknown>;\n flat?: unknown;\n} {\n // Top-level fields (from inline DataItem) take precedence.\n // Use explicit `undefined` checks rather than `??` so that falsy values\n // like `null`, `false`, `0`, `\"\"` are treated as valid resume payloads.\n const perStep =\n item.resumeSteps !== undefined\n ? item.resumeSteps\n : (item.metadata?.resumeSteps as Record<string, unknown> | undefined);\n const flat = item.resumeData !== undefined ? item.resumeData : item.metadata?.resumeData;\n return { perStep, flat };\n}\n\n/**\n * Execute a dataset item against a workflow.\n * Creates a run with scorers disabled to avoid double-scoring.\n *\n * When the workflow suspends, checks for resume data in `item.metadata`\n * (via `resumeSteps` keyed by step ID or `resumeData` for single-step workflows)\n * and automatically resumes. Loops through multiple suspend/resume cycles up to\n * MAX_RESUME_CYCLES to support multi-step suspend workflows.\n *\n * Mirrors `executeWorkflow` in evals/run so dataset experiments and runEvals\n * produce the same observability spans and scoring data for workflow targets.\n */\nasync function executeWorkflow(\n workflow: Workflow,\n item: {\n input: unknown;\n groundTruth?: unknown;\n metadata?: Record<string, unknown>;\n resumeSteps?: Record<string, unknown>;\n resumeData?: unknown;\n },\n requestContext?: Record<string, unknown>,\n): Promise<ExecutionResult> {\n const reqCtx: RequestContext | undefined = requestContext\n ? new RequestContext(Object.entries(requestContext))\n : undefined;\n const observabilityContext = resolveObservabilityContext({});\n\n const run = await workflow.createRun({ disableScorers: true });\n let result = await run.start({\n inputData: item.input,\n ...(reqCtx ? { requestContext: reqCtx } : {}),\n ...observabilityContext,\n });\n\n // Auto-resume loop: if the workflow suspends and resume data is provided,\n // resume the workflow automatically. Cap iterations to prevent infinite loops.\n const { perStep, flat } = extractResumeData(item);\n const hasResumeData = perStep !== undefined || flat !== undefined;\n\n if (hasResumeData) {\n let cycle = 0;\n while (result.status === 'suspended' && cycle < MAX_RESUME_CYCLES) {\n cycle++;\n\n // Determine which steps are suspended\n const suspendedPaths: string[][] = result.suspended ?? [];\n if (suspendedPaths.length === 0) break;\n\n // For each suspended step, look up resume data\n const firstSuspendedStep = suspendedPaths[0]?.[0];\n if (!firstSuspendedStep) break;\n\n // Resolve resume data: per-step map takes precedence, then flat fallback.\n // Use explicit undefined check so falsy values (null, false, 0) are forwarded.\n const perStepValue = perStep?.[firstSuspendedStep];\n const stepResumeData = perStepValue !== undefined ? perStepValue : flat;\n if (stepResumeData === undefined) break; // No data for this step, stop resuming\n\n result = await run.resume({\n resumeData: stepResumeData,\n step: firstSuspendedStep,\n ...(reqCtx ? { requestContext: reqCtx } : {}),\n ...observabilityContext,\n });\n }\n }\n\n return handleWorkflowResult(result);\n}\n\n/**\n * Map a terminal WorkflowResult to an ExecutionResult.\n * Uses a loose `result: any` parameter because WorkflowResult is heavily generic;\n * status-narrowing guards below keep accesses safe.\n */\n\nfunction handleWorkflowResult(result: any): ExecutionResult {\n // TracingProperties is intersected on every WorkflowResult variant\n const traceId = result.traceId ?? null;\n const spanId = result.spanId ?? null;\n\n if (result.status === 'success') {\n return {\n output: result.result,\n error: null,\n traceId,\n spanId,\n stepResults: result.steps as Record<string, StepResult<any, any, any, any>>,\n stepExecutionPath: result.stepExecutionPath,\n };\n }\n\n if (result.status === 'failed') {\n return {\n output: null,\n error: { message: result.error?.message ?? 'Workflow failed', stack: result.error?.stack },\n traceId,\n spanId,\n stepResults: result.steps as Record<string, StepResult<any, any, any, any>>,\n stepExecutionPath: result.stepExecutionPath,\n };\n }\n\n if (result.status === 'tripwire') {\n return {\n output: null,\n error: { message: `Workflow tripwire: ${result.tripwire?.reason ?? 'Unknown reason'}` },\n traceId,\n spanId,\n stepResults: result.steps as Record<string, StepResult<any, any, any, any>>,\n stepExecutionPath: result.stepExecutionPath,\n };\n }\n\n if (result.status === 'suspended') {\n // Workflow suspended but no resume data was provided (or exhausted).\n // Return partial results with suspend payload for debugging.\n return {\n output: result.suspendPayload ?? null,\n error: {\n message:\n 'Workflow suspended — provide resume data via item.resumeSteps/item.resumeData (or metadata.resumeSteps/metadata.resumeData) to auto-resume',\n },\n traceId,\n spanId,\n stepResults: result.steps as Record<string, StepResult<any, any, any, any>>,\n stepExecutionPath: result.stepExecutionPath,\n };\n }\n\n if (result.status === 'paused') {\n return {\n output: null,\n error: { message: 'Workflow paused - not yet supported in dataset experiments' },\n traceId,\n spanId,\n stepResults: result.steps as Record<string, StepResult<any, any, any, any>>,\n stepExecutionPath: result.stepExecutionPath,\n };\n }\n\n // Catch-all for any other status\n return {\n output: null,\n error: { message: `Workflow ended with unexpected status: ${result.status}` },\n traceId,\n spanId,\n };\n}\n","import { ScorerRunError } from '../../evals/base';\nimport type { MastraScorer } from '../../evals/base';\nimport { extractTrajectory, extractTrajectoryFromTrace } from '../../evals/types';\nimport type { ScorerRunInputForAgent, ScorerRunOutputForAgent, Trajectory } from '../../evals/types';\nimport type { Mastra } from '../../mastra';\nimport { validateAndSaveScore } from '../../mastra/hooks';\nimport { EntityType } from '../../observability';\nimport type { CorrelationContext } from '../../observability';\nimport type { MastraCompositeStore } from '../../storage/base';\nimport type { TargetType } from '../../storage/types';\nimport type { StepResult } from '../../workflows';\nimport type { ScorerResult } from './types';\n\nfunction toScorerTargetEntityType(targetType?: TargetType): EntityType | undefined {\n switch (targetType) {\n case 'agent':\n return EntityType.AGENT;\n case 'workflow':\n return EntityType.WORKFLOW_RUN;\n case 'scorer':\n return EntityType.SCORER;\n default:\n return undefined;\n }\n}\n\nfunction getItemScorerById(mastra: Mastra, scorerId: string): MastraScorer<any, any, any, any> | null {\n try {\n return mastra.getScorerById(scorerId) ?? null;\n } catch {\n return null;\n }\n}\n\n/**\n * Resolve scorers from mixed array of instances and string IDs.\n * String IDs are looked up from Mastra's scorer registry.\n */\nexport function resolveScorers(\n mastra: Mastra,\n scorers?: (MastraScorer<any, any, any, any> | string)[],\n): MastraScorer<any, any, any, any>[] {\n if (!scorers || scorers.length === 0) return [];\n\n return scorers\n .map(scorer => {\n if (typeof scorer === 'string') {\n const resolved = mastra.getScorerById(scorer);\n if (!resolved) {\n console.warn(`Scorer not found: ${scorer}`);\n return null;\n }\n return resolved;\n }\n return scorer;\n })\n .filter((s): s is MastraScorer<any, any, any, any> => s !== null);\n}\n\nexport const EXPERIMENT_ITEM_SCORER_NOT_FOUND = 'EXPERIMENT_ITEM_SCORER_NOT_FOUND';\n\ntype ResolvedScorer = MastraScorer<any, any, any, any>;\n\nexport interface ItemScorerResolution {\n scorers: ResolvedScorer[];\n missingIds: string[];\n}\n\n/**\n * Create a run-scoped resolver for item scorer IDs. Resolutions, including misses,\n * are cached so concurrent items hydrate a stored scorer at most once per run.\n */\nexport function createItemScorerResolver(mastra: Mastra): (scorerIds: string[]) => Promise<ItemScorerResolution> {\n const resolutionCache = new Map<string, Promise<ResolvedScorer | null>>();\n\n const resolveById = (scorerId: string): Promise<ResolvedScorer | null> => {\n const cached = resolutionCache.get(scorerId);\n if (cached) return cached;\n\n const resolution = (async () => {\n let scorer = getItemScorerById(mastra, scorerId);\n if (scorer) return scorer;\n\n const editor = mastra.getEditor?.();\n if (editor) {\n try {\n await editor.scorer.getById(scorerId);\n } catch {\n // A missing or unavailable stored scorer is handled as an unresolved item reference below.\n }\n scorer = getItemScorerById(mastra, scorerId);\n }\n\n return scorer;\n })();\n\n resolutionCache.set(scorerId, resolution);\n return resolution;\n };\n\n return async scorerIds => {\n const uniqueIds = [...new Set(scorerIds)];\n const resolved = await Promise.all(uniqueIds.map(async id => ({ id, scorer: await resolveById(id) })));\n\n return {\n scorers: resolved.flatMap(({ scorer }) => (scorer ? [scorer] : [])),\n missingIds: resolved.flatMap(({ id, scorer }) => (scorer ? [] : [id])),\n };\n };\n}\n\n/**\n * Attempt to extract a Trajectory from the observability trace store.\n * Falls back to undefined if storage is unavailable or the trace has no spans.\n */\nasync function extractTrajectoryFromStorage(\n storage: MastraCompositeStore | null,\n traceId?: string,\n): Promise<Trajectory | undefined> {\n if (!storage || !traceId) return undefined;\n try {\n const observabilityStore = await storage.getStore('observability');\n if (!observabilityStore) return undefined;\n const trace = await observabilityStore.getTrace({ traceId });\n if (!trace?.spans?.length) return undefined;\n return extractTrajectoryFromTrace(trace.spans);\n } catch {\n return undefined;\n }\n}\n\n/**\n * Workflow-specific data forwarded to scorers so they can inspect step-level\n * input/output and the executed step path. Surfaced via `targetMetadata` on\n * the scorer run so existing scorer signatures stay unchanged.\n */\nexport interface WorkflowScorerData {\n stepResults?: Record<string, StepResult<any, any, any, any>>;\n stepExecutionPath?: string[];\n spanId?: string | null;\n}\n\n/**\n * Run all scorers for a single item result.\n * Errors are isolated per scorer - one failing scorer doesn't affect others.\n * Trajectory scorers (scorer.type === 'trajectory') receive a pre-extracted\n * Trajectory as their output, mirroring the dispatch runEvals performs.\n *\n * `persistScores: false` suppresses score writes while leaving `storage` usable\n * for reads. The two are deliberately separate parameters: `storage` is also the\n * source for trajectory extraction, so nulling it to stop writes would silently\n * downgrade trajectory scorers to the raw-message fallback.\n */\nexport async function runScorersForItem(\n scorers: MastraScorer<any, any, any, any>[],\n item: { input: unknown; groundTruth?: unknown; metadata?: Record<string, unknown> },\n output: unknown,\n storage: MastraCompositeStore | null,\n runId: string,\n targetType: TargetType,\n targetId: string,\n itemId: string,\n scorerInput?: ScorerRunInputForAgent,\n scorerOutput?: ScorerRunOutputForAgent,\n traceId?: string,\n workflowData?: WorkflowScorerData,\n persistScores: boolean = true,\n): Promise<ScorerResult[]> {\n if (scorers.length === 0) return [];\n\n // Pre-extract trajectory once for all trajectory scorers in this batch.\n // Try the trace store first (requires observability storage + traceId), then\n // fall back to extracting from the raw MastraDBMessage[] scoring output.\n const hasTrajectoryScorer = scorers.some(s => s.type === 'trajectory');\n let trajectoryOutput: Trajectory | undefined;\n if (hasTrajectoryScorer) {\n const traceTrajectory = await extractTrajectoryFromStorage(storage, traceId);\n trajectoryOutput = traceTrajectory ?? (scorerOutput ? extractTrajectory(scorerOutput) : { steps: [] });\n }\n\n // Build correlation context so scorers can emit scores with full experiment context\n const targetCorrelationContext: CorrelationContext = {\n ...(traceId ? { traceId } : {}),\n entityType: toScorerTargetEntityType(targetType),\n entityId: targetId,\n entityName: targetId,\n experimentId: runId,\n };\n\n const settled = await Promise.allSettled(\n scorers.map(async scorer => {\n const { result, promptMetadata } = await runScorerSafe(\n scorer,\n item,\n output,\n scorerInput,\n scorerOutput,\n targetType,\n traceId,\n targetCorrelationContext,\n scorer.type === 'trajectory' ? trajectoryOutput : undefined,\n workflowData,\n persistScores,\n );\n\n // Persist only scores from successful scorer runs.\n if (persistScores && storage && result.error === null && result.score !== null) {\n try {\n // Legacy score-store emission. This path is being deprecated.\n await validateAndSaveScore(storage, {\n scorerId: scorer.id,\n score: result.score,\n reason: result.reason ?? undefined,\n input: item.input,\n output,\n additionalContext: item.metadata,\n entityType: targetType.toUpperCase(),\n entityId: itemId,\n source: 'TEST',\n runId,\n traceId,\n scorer: {\n id: scorer.id,\n name: scorer.name,\n description: scorer.description ?? '',\n hasJudge: !!scorer.judge,\n },\n entity: {\n id: targetId,\n name: targetId,\n },\n ...promptMetadata,\n });\n } catch (saveError) {\n // TODO: Remove this warning path once the old scores storage is deprecated.\n // Log but don't fail - score persistence is best-effort\n console.warn(`Failed to save score for scorer ${scorer.id}:`, saveError);\n }\n }\n\n return result;\n }),\n );\n\n return settled.map((s, i) => {\n if (s.status === 'fulfilled') return s.value;\n const scorer = scorers[i]!;\n return {\n scorerId: scorer.id,\n scorerName: scorer.name,\n score: null,\n reason: null,\n error: String(s.reason),\n targetScope: scorer.type === 'trajectory' ? 'trajectory' : 'span',\n };\n });\n}\n\n/** Prompt/step metadata returned by scorer.run() for DB persistence. */\ninterface ScorerPromptMetadata {\n generateScorePrompt?: string;\n generateReasonPrompt?: string;\n preprocessStepResult?: Record<string, unknown>;\n preprocessPrompt?: string;\n analyzeStepResult?: Record<string, unknown>;\n analyzePrompt?: string;\n}\n\nfunction extractScorerRunFields(scoreResult: unknown): {\n score: number | null;\n reason: string | null;\n promptMetadata: ScorerPromptMetadata;\n} {\n if (typeof scoreResult !== 'object' || scoreResult === null) {\n return { score: null, reason: null, promptMetadata: {} };\n }\n\n const fields = scoreResult as Record<string, unknown>;\n const str = (key: string): string | undefined =>\n typeof fields[key] === 'string' ? (fields[key] as string) : undefined;\n const obj = (key: string): Record<string, unknown> | undefined => {\n const value = fields[key];\n return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : undefined;\n };\n\n return {\n score: typeof fields.score === 'number' ? fields.score : null,\n reason: typeof fields.reason === 'string' ? fields.reason : null,\n promptMetadata: {\n generateScorePrompt: str('generateScorePrompt'),\n generateReasonPrompt: str('generateReasonPrompt'),\n preprocessStepResult: obj('preprocessStepResult'),\n preprocessPrompt: str('preprocessPrompt'),\n analyzeStepResult: obj('analyzeStepResult'),\n analyzePrompt: str('analyzePrompt'),\n },\n };\n}\n\n/**\n * Run a single scorer safely, catching any errors.\n * Returns both the ScorerResult and prompt metadata for DB persistence.\n * When trajectoryOutput is provided the scorer receives it as run.output,\n * honoring the type: 'trajectory' contract.\n */\nasync function runScorerSafe(\n scorer: MastraScorer<any, any, any, any>,\n item: { input: unknown; groundTruth?: unknown; metadata?: Record<string, unknown> },\n output: unknown,\n scorerInput?: ScorerRunInputForAgent,\n scorerOutput?: ScorerRunOutputForAgent,\n targetType?: TargetType,\n targetTraceId?: string,\n targetCorrelationContext?: CorrelationContext,\n trajectoryOutput?: Trajectory,\n workflowData?: WorkflowScorerData,\n persistScores: boolean = true,\n): Promise<{ result: ScorerResult; promptMetadata: ScorerPromptMetadata }> {\n try {\n const effectiveOutput = trajectoryOutput ?? scorerOutput ?? output;\n const effectiveScope = trajectoryOutput ? 'trajectory' : 'span';\n\n // Surface step-level data via targetMetadata so workflow scorers can\n // inspect per-step input/output without changing the scorer signature.\n // Trajectory scorers already receive the Trajectory as their output, so\n // the step metadata is only relevant for non-trajectory workflow scorers.\n const targetMetadata: Record<string, unknown> | undefined =\n !trajectoryOutput && workflowData && (workflowData.stepResults || workflowData.stepExecutionPath)\n ? {\n ...(workflowData.stepResults ? { stepResults: workflowData.stepResults } : {}),\n ...(workflowData.stepExecutionPath ? { stepExecutionPath: workflowData.stepExecutionPath } : {}),\n }\n : undefined;\n\n const scoreResult: unknown = await scorer.run({\n input: scorerInput ?? item.input,\n output: effectiveOutput,\n groundTruth: item.groundTruth,\n scoreSource: 'experiment',\n targetScope: effectiveScope,\n targetEntityType: toScorerTargetEntityType(targetType),\n targetTraceId,\n ...(workflowData?.spanId ? { targetSpanId: workflowData.spanId } : {}),\n ...(targetCorrelationContext ? { targetCorrelationContext } : {}),\n ...(targetMetadata ? { targetMetadata } : {}),\n _internal: { emitObservabilityScore: persistScores },\n });\n\n // Extract fields with typeof guards — scorer run result types use complex\n // conditional generics that don't resolve cleanly with MastraScorer<any,…>.\n if (typeof scoreResult !== 'object' || scoreResult === null) {\n return {\n result: {\n scorerId: scorer.id,\n scorerName: scorer.name,\n score: null,\n reason: null,\n error: `Scorer ${scorer.name} (${scorer.id}) returned invalid result: expected object, got ${scoreResult === null ? 'null' : typeof scoreResult} (${String(scoreResult)})`,\n },\n promptMetadata: {},\n };\n }\n\n const { score, reason, promptMetadata } = extractScorerRunFields(scoreResult);\n\n return {\n result: {\n scorerId: scorer.id,\n scorerName: scorer.name,\n score,\n reason,\n error: null,\n targetScope: effectiveScope,\n },\n promptMetadata,\n };\n } catch (error) {\n if (error instanceof ScorerRunError) {\n const { score, reason, promptMetadata } = extractScor