@mastra/evals 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +58 -0
- package/dist/checks-64AonnEK.js +379 -0
- package/dist/checks-64AonnEK.js.map +1 -0
- package/dist/checks-DGTgg-nW.cjs +479 -0
- package/dist/checks-DGTgg-nW.cjs.map +1 -0
- package/dist/checks.cjs +14 -56
- package/dist/checks.js +2 -3
- package/dist/docs/SKILL.md +2 -1
- package/dist/docs/assets/SOURCE_MAP.json +1 -1
- package/dist/docs/references/docs-evals-built-in-scorers.md +4 -4
- package/dist/docs/references/docs-evals-overview.md +6 -4
- package/dist/docs/references/docs-evals-quick-checks.md +2 -2
- package/dist/docs/references/reference-evals-answer-relevancy.md +5 -5
- package/dist/docs/references/reference-evals-answer-similarity.md +1 -1
- package/dist/docs/references/reference-evals-bias.md +4 -4
- package/dist/docs/references/reference-evals-checks.md +3 -3
- package/dist/docs/references/reference-evals-completeness.md +5 -5
- package/dist/docs/references/reference-evals-content-similarity.md +5 -5
- package/dist/docs/references/reference-evals-context-precision.md +5 -5
- package/dist/docs/references/reference-evals-context-recall.md +11 -11
- package/dist/docs/references/reference-evals-context-relevance.md +15 -15
- package/dist/docs/references/reference-evals-faithfulness.md +4 -4
- package/dist/docs/references/reference-evals-hallucination.md +11 -11
- package/dist/docs/references/reference-evals-keyword-coverage.md +6 -6
- package/dist/docs/references/reference-evals-noise-sensitivity.md +15 -15
- package/dist/docs/references/reference-evals-prompt-alignment.md +20 -20
- package/dist/docs/references/reference-evals-rubric.md +2 -2
- package/dist/docs/references/reference-evals-scorer-utils.md +4 -4
- package/dist/docs/references/reference-evals-summarization.md +203 -0
- package/dist/docs/references/reference-evals-textual-difference.md +4 -4
- package/dist/docs/references/reference-evals-tool-call-accuracy.md +4 -4
- package/dist/docs/references/reference-evals-toxicity.md +5 -5
- package/dist/docs/references/reference-evals-trajectory-accuracy.md +10 -10
- package/dist/index.cjs +12 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +13 -1
- package/dist/index.js.map +1 -1
- package/dist/scorers/llm/index.d.ts +1 -0
- package/dist/scorers/llm/index.d.ts.map +1 -1
- package/dist/scorers/llm/summarization/index.d.ts +55 -0
- package/dist/scorers/llm/summarization/index.d.ts.map +1 -0
- package/dist/scorers/llm/summarization/prompts.d.ts +48 -0
- package/dist/scorers/llm/summarization/prompts.d.ts.map +1 -0
- package/dist/scorers/prebuilt/index.cjs +2753 -2848
- package/dist/scorers/prebuilt/index.cjs.map +1 -1
- package/dist/scorers/prebuilt/index.js +2735 -2791
- package/dist/scorers/prebuilt/index.js.map +1 -1
- package/dist/scorers/utils.cjs +966 -101
- package/dist/scorers/utils.cjs.map +1 -1
- package/dist/scorers/utils.js +939 -2
- package/dist/scorers/utils.js.map +1 -1
- package/package.json +11 -10
- package/dist/checks.cjs.map +0 -1
- package/dist/checks.js.map +0 -1
- package/dist/chunk-GGHVFNVI.cjs +0 -233
- package/dist/chunk-GGHVFNVI.cjs.map +0 -1
- package/dist/chunk-IZLA36WC.cjs +0 -654
- package/dist/chunk-IZLA36WC.cjs.map +0 -1
- package/dist/chunk-UJ4WCQ3F.js +0 -626
- package/dist/chunk-UJ4WCQ3F.js.map +0 -1
- package/dist/chunk-WEADJCUA.js +0 -216
- package/dist/chunk-WEADJCUA.js.map +0 -1
package/dist/scorers/utils.js
CHANGED
|
@@ -1,3 +1,940 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { extractTrajectory } from "@mastra/core/evals";
|
|
2
|
+
import { RequestContext } from "@mastra/core/request-context";
|
|
3
|
+
//#region src/scorers/utils.ts
|
|
4
|
+
/**
|
|
5
|
+
* Extracts text content from a MastraDBMessage or ModelMessage-like object.
|
|
6
|
+
*
|
|
7
|
+
* @param message - The message to extract text from
|
|
8
|
+
* @returns The extracted text content, or an empty string if no text is found
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* const message: MastraDBMessage = {
|
|
13
|
+
* id: 'msg-1',
|
|
14
|
+
* role: 'assistant',
|
|
15
|
+
* content: { format: 2, parts: [{ type: 'text', text: 'Hello!' }] },
|
|
16
|
+
* createdAt: new Date(),
|
|
17
|
+
* };
|
|
18
|
+
* const text = getTextContentFromMastraDBMessage(message); // 'Hello!'
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
function getTextContentFromMastraDBMessage(message) {
|
|
22
|
+
const content = message.content;
|
|
23
|
+
if (typeof content === "string") return content;
|
|
24
|
+
if (Array.isArray(content)) {
|
|
25
|
+
const textParts = content.filter((p) => p.type === "text");
|
|
26
|
+
return textParts.length > 0 ? textParts[textParts.length - 1]?.text || "" : "";
|
|
27
|
+
}
|
|
28
|
+
if (typeof content?.content === "string" && content.content !== "") return content.content;
|
|
29
|
+
if (typeof content?.text === "string" && content.text !== "") return content.text;
|
|
30
|
+
if (content?.parts && Array.isArray(content.parts)) {
|
|
31
|
+
const textParts = content.parts.filter((p) => p.type === "text");
|
|
32
|
+
return textParts.length > 0 ? textParts[textParts.length - 1]?.text || "" : "";
|
|
33
|
+
}
|
|
34
|
+
return "";
|
|
35
|
+
}
|
|
36
|
+
const isRecord = (value) => {
|
|
37
|
+
return typeof value === "object" && value !== null;
|
|
38
|
+
};
|
|
39
|
+
const getTextFromValue = (value) => {
|
|
40
|
+
if (typeof value === "string") return value === "" ? void 0 : value;
|
|
41
|
+
if (Array.isArray(value)) {
|
|
42
|
+
const textParts = value.filter((part) => isRecord(part) && part.type === "text" && typeof part.text === "string").map((part) => part.text);
|
|
43
|
+
return textParts.length > 0 ? textParts[textParts.length - 1] : void 0;
|
|
44
|
+
}
|
|
45
|
+
if (!isRecord(value)) return void 0;
|
|
46
|
+
const fromParts = Array.isArray(value.parts) ? getTextFromValue(value.parts) : void 0;
|
|
47
|
+
return getTextFromValue(value.content) ?? (typeof value.text === "string" && value.text !== "" ? value.text : void 0) ?? (typeof value.body === "string" && value.body !== "" ? value.body : void 0) ?? fromParts;
|
|
48
|
+
};
|
|
49
|
+
const isScorerRunInputForAgent = (input) => {
|
|
50
|
+
return isRecord(input) && Array.isArray(input.inputMessages) && Array.isArray(input.rememberedMessages) && Array.isArray(input.systemMessages) && isRecord(input.taggedSystemMessages);
|
|
51
|
+
};
|
|
52
|
+
const isMastraDBMessageLike = (message) => {
|
|
53
|
+
return isRecord(message) && typeof message.id === "string" && typeof message.role === "string" && "content" in message && "createdAt" in message;
|
|
54
|
+
};
|
|
55
|
+
const isScorerRunOutputForAgent = (output) => {
|
|
56
|
+
return Array.isArray(output) && output.every(isMastraDBMessageLike);
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Resolves the effective role of a message, accounting for agent signal messages.
|
|
60
|
+
*
|
|
61
|
+
* Messages delivered through the agent subscription / signal API are persisted with
|
|
62
|
+
* `role: 'signal'` and carry their semantic role (e.g. `user`) on `type` and on
|
|
63
|
+
* `content.metadata.signal.{type,tagName}`. Treat those as their underlying role so
|
|
64
|
+
* helpers like `getUserMessageFromRunInput` can find them.
|
|
65
|
+
*/
|
|
66
|
+
const getEffectiveMessageRole = (message) => {
|
|
67
|
+
if (message.role !== "signal") return typeof message.role === "string" ? message.role : void 0;
|
|
68
|
+
const signalMeta = isRecord(message.content) && isRecord(message.content.metadata) ? message.content.metadata.signal : void 0;
|
|
69
|
+
const tagName = isRecord(signalMeta) && typeof signalMeta.tagName === "string" ? signalMeta.tagName : void 0;
|
|
70
|
+
const signalType = isRecord(signalMeta) && typeof signalMeta.type === "string" ? signalMeta.type : void 0;
|
|
71
|
+
const topLevelType = typeof message.type === "string" ? message.type : void 0;
|
|
72
|
+
return tagName ?? signalType ?? topLevelType;
|
|
73
|
+
};
|
|
74
|
+
const getTextFromMessages = (messages, role) => {
|
|
75
|
+
if (!Array.isArray(messages)) return void 0;
|
|
76
|
+
const message = messages.find((message) => isRecord(message) && getEffectiveMessageRole(message) === role);
|
|
77
|
+
return message ? getTextFromValue(message) : void 0;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Rounds a number to two decimal places.
|
|
81
|
+
*
|
|
82
|
+
* Uses `Number.EPSILON` to handle floating-point precision issues.
|
|
83
|
+
*
|
|
84
|
+
* @param num - The number to round
|
|
85
|
+
* @returns The number rounded to two decimal places
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* ```ts
|
|
89
|
+
* roundToTwoDecimals(0.1 + 0.2); // 0.3
|
|
90
|
+
* roundToTwoDecimals(1.005); // 1.01
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
const roundToTwoDecimals = (num) => {
|
|
94
|
+
return Math.round((num + Number.EPSILON) * 100) / 100;
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Determines if a value is closer to the first target than the second.
|
|
98
|
+
*
|
|
99
|
+
* @param value - The value to compare
|
|
100
|
+
* @param target1 - The first target value
|
|
101
|
+
* @param target2 - The second target value
|
|
102
|
+
* @returns `true` if `value` is closer to `target1` than `target2`
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* ```ts
|
|
106
|
+
* isCloserTo(0.6, 1, 0); // true (0.6 is closer to 1)
|
|
107
|
+
* isCloserTo(0.3, 1, 0); // false (0.3 is closer to 0)
|
|
108
|
+
* ```
|
|
109
|
+
*/
|
|
110
|
+
function isCloserTo(value, target1, target2) {
|
|
111
|
+
return Math.abs(value - target1) < Math.abs(value - target2);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Creates a scoring input object for testing purposes.
|
|
115
|
+
*
|
|
116
|
+
* @param input - The user input text
|
|
117
|
+
* @param output - The assistant output text
|
|
118
|
+
* @param additionalContext - Optional additional context data
|
|
119
|
+
* @param requestContext - Optional request context data
|
|
120
|
+
* @returns A ScoringInput object ready for use in scorer tests
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* ```ts
|
|
124
|
+
* const run = createTestRun(
|
|
125
|
+
* 'What is 2+2?',
|
|
126
|
+
* 'The answer is 4.',
|
|
127
|
+
* { topic: 'math' }
|
|
128
|
+
* );
|
|
129
|
+
* ```
|
|
130
|
+
*/
|
|
131
|
+
const createTestRun = (input, output, additionalContext, requestContext) => {
|
|
132
|
+
return {
|
|
133
|
+
input: [{
|
|
134
|
+
role: "user",
|
|
135
|
+
content: input
|
|
136
|
+
}],
|
|
137
|
+
output: {
|
|
138
|
+
role: "assistant",
|
|
139
|
+
text: output
|
|
140
|
+
},
|
|
141
|
+
additionalContext: additionalContext ?? {},
|
|
142
|
+
requestContext: requestContext ?? {}
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* Extracts the user message text from a scorer run input.
|
|
147
|
+
*
|
|
148
|
+
* Accepts the agent shape (`{ inputMessages }`), `ModelMessage[]`
|
|
149
|
+
* (`{ messages }`), workflow input (`{ prompt }`), and a bare string.
|
|
150
|
+
*
|
|
151
|
+
* @param input - The scorer run input
|
|
152
|
+
* @returns The user message text, or `undefined` if none can be extracted
|
|
153
|
+
*
|
|
154
|
+
* @example
|
|
155
|
+
* ```ts
|
|
156
|
+
* const scorer = createScorer({ ... })
|
|
157
|
+
* .preprocess(({ run }) => {
|
|
158
|
+
* const userText = getUserMessageFromRunInput(run.input);
|
|
159
|
+
* return { userText };
|
|
160
|
+
* });
|
|
161
|
+
* ```
|
|
162
|
+
*/
|
|
163
|
+
const getUserMessageFromRunInput = (input) => {
|
|
164
|
+
if (typeof input === "string") return input;
|
|
165
|
+
if (!isRecord(input)) return void 0;
|
|
166
|
+
return getTextFromMessages(input.inputMessages, "user") ?? getTextFromMessages(input.messages, "user") ?? (typeof input.prompt === "string" ? input.prompt : void 0) ?? (typeof input.text === "string" ? input.text : void 0) ?? getTextFromValue(input.content) ?? getTextFromValue(input.input) ?? getTextFromValue(input.user);
|
|
167
|
+
};
|
|
168
|
+
/**
|
|
169
|
+
* Extracts all system messages from a scorer run input.
|
|
170
|
+
*
|
|
171
|
+
* Collects text from both standard system messages and tagged system messages
|
|
172
|
+
* (specialized system prompts like memory instructions).
|
|
173
|
+
*
|
|
174
|
+
* @param input - The scorer run input containing system messages
|
|
175
|
+
* @returns An array of system message strings
|
|
176
|
+
*
|
|
177
|
+
* @example
|
|
178
|
+
* ```ts
|
|
179
|
+
* const scorer = createScorer({ ... })
|
|
180
|
+
* .preprocess(({ run }) => {
|
|
181
|
+
* const systemMessages = getSystemMessagesFromRunInput(run.input);
|
|
182
|
+
* return { systemPrompt: systemMessages.join('\n') };
|
|
183
|
+
* });
|
|
184
|
+
* ```
|
|
185
|
+
*/
|
|
186
|
+
const getSystemMessagesFromRunInput = (input) => {
|
|
187
|
+
const systemMessages = [];
|
|
188
|
+
if (!isRecord(input)) return systemMessages;
|
|
189
|
+
if (Array.isArray(input.systemMessages)) systemMessages.push(...input.systemMessages.map((msg) => {
|
|
190
|
+
if (typeof msg.content === "string") return msg.content;
|
|
191
|
+
else if (Array.isArray(msg.content)) return msg.content.filter((part) => part.type === "text").map((part) => part.text || "").join(" ");
|
|
192
|
+
return "";
|
|
193
|
+
}).filter((content) => content));
|
|
194
|
+
const addSystemMessages = (messages) => {
|
|
195
|
+
if (!Array.isArray(messages)) return;
|
|
196
|
+
systemMessages.push(...messages.filter((message) => isRecord(message) && message.role === "system").map((message) => getTextFromValue(message)).filter((content) => Boolean(content)));
|
|
197
|
+
};
|
|
198
|
+
addSystemMessages(input.inputMessages);
|
|
199
|
+
addSystemMessages(input.messages);
|
|
200
|
+
if (isRecord(input.taggedSystemMessages)) Object.values(input.taggedSystemMessages).forEach((messages) => {
|
|
201
|
+
if (!Array.isArray(messages)) return;
|
|
202
|
+
messages.forEach((msg) => {
|
|
203
|
+
const content = getTextFromValue(msg);
|
|
204
|
+
if (content) systemMessages.push(content);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
return systemMessages;
|
|
208
|
+
};
|
|
209
|
+
/**
|
|
210
|
+
* Combines all system messages into a single prompt string.
|
|
211
|
+
*
|
|
212
|
+
* Joins all system messages (standard and tagged) with double newlines.
|
|
213
|
+
*
|
|
214
|
+
* @param input - The scorer run input containing system messages
|
|
215
|
+
* @returns A combined system prompt string
|
|
216
|
+
*
|
|
217
|
+
* @example
|
|
218
|
+
* ```ts
|
|
219
|
+
* const scorer = createScorer({ ... })
|
|
220
|
+
* .preprocess(({ run }) => {
|
|
221
|
+
* const systemPrompt = getCombinedSystemPrompt(run.input);
|
|
222
|
+
* return { systemPrompt };
|
|
223
|
+
* });
|
|
224
|
+
* ```
|
|
225
|
+
*/
|
|
226
|
+
const getCombinedSystemPrompt = (input) => {
|
|
227
|
+
return getSystemMessagesFromRunInput(input).join("\n\n");
|
|
228
|
+
};
|
|
229
|
+
/**
|
|
230
|
+
* Extracts the assistant message text from a scorer run output.
|
|
231
|
+
*
|
|
232
|
+
* Accepts the agent shape (`MastraDBMessage[]` / `ModelMessage[]`), workflow
|
|
233
|
+
* output (`{ text }`), task output (`{ content }`), a single assistant message
|
|
234
|
+
* object, and a bare string.
|
|
235
|
+
*
|
|
236
|
+
* @param output - The scorer run output
|
|
237
|
+
* @returns The assistant message text, or `undefined` if none can be extracted
|
|
238
|
+
*
|
|
239
|
+
* @example
|
|
240
|
+
* ```ts
|
|
241
|
+
* const scorer = createScorer({ ... })
|
|
242
|
+
* .preprocess(({ run }) => {
|
|
243
|
+
* const response = getAssistantMessageFromRunOutput(run.output);
|
|
244
|
+
* return { response };
|
|
245
|
+
* });
|
|
246
|
+
* ```
|
|
247
|
+
*/
|
|
248
|
+
const getAssistantMessageFromRunOutput = (output) => {
|
|
249
|
+
if (typeof output === "string") return output;
|
|
250
|
+
if (Array.isArray(output)) return getTextFromMessages(output, "assistant");
|
|
251
|
+
if (!isRecord(output)) return void 0;
|
|
252
|
+
const isAssistantOutput = output.role === void 0 || output.role === "assistant";
|
|
253
|
+
if (isAssistantOutput && typeof output.text === "string") return output.text;
|
|
254
|
+
if (isAssistantOutput && typeof output.content === "string") return output.content;
|
|
255
|
+
if (isAssistantOutput && (isRecord(output.content) || Array.isArray(output.content))) return getTextContentFromMastraDBMessage(output) || getTextContentFromMastraDBMessage(output.content) || void 0;
|
|
256
|
+
if (output.role === "assistant") return getTextContentFromMastraDBMessage(output) || void 0;
|
|
257
|
+
};
|
|
258
|
+
/**
|
|
259
|
+
* Extracts reasoning text from a scorer run output.
|
|
260
|
+
*
|
|
261
|
+
* This function extracts reasoning content from assistant messages, which is
|
|
262
|
+
* produced by reasoning models like `deepseek-reasoner`. The reasoning can be
|
|
263
|
+
* stored in two places:
|
|
264
|
+
* 1. `content.reasoning` - a string field on the message content
|
|
265
|
+
* 2. `content.parts` - as parts with `type: 'reasoning'` containing `details`
|
|
266
|
+
*
|
|
267
|
+
* @param output - The scorer run output (array of MastraDBMessage)
|
|
268
|
+
* @returns The reasoning text, or `undefined` if no reasoning is present
|
|
269
|
+
*
|
|
270
|
+
* @example
|
|
271
|
+
* ```ts
|
|
272
|
+
* const reasoningScorer = createScorer({
|
|
273
|
+
* id: 'reasoning-scorer',
|
|
274
|
+
* name: 'Reasoning Quality',
|
|
275
|
+
* description: 'Evaluates the quality of model reasoning',
|
|
276
|
+
* type: 'agent',
|
|
277
|
+
* })
|
|
278
|
+
* .preprocess(({ run }) => {
|
|
279
|
+
* const reasoning = getReasoningFromRunOutput(run.output);
|
|
280
|
+
* const response = getAssistantMessageFromRunOutput(run.output);
|
|
281
|
+
* return { reasoning, response };
|
|
282
|
+
* })
|
|
283
|
+
* .generateScore(({ results }) => {
|
|
284
|
+
* // Score based on reasoning quality
|
|
285
|
+
* return results.preprocessStepResult?.reasoning ? 1 : 0;
|
|
286
|
+
* });
|
|
287
|
+
* ```
|
|
288
|
+
*/
|
|
289
|
+
const getReasoningFromRunOutput = (output) => {
|
|
290
|
+
if (!output) return void 0;
|
|
291
|
+
const message = output.find(({ role }) => role === "assistant");
|
|
292
|
+
if (!message) return void 0;
|
|
293
|
+
if (message.content.reasoning) return message.content.reasoning;
|
|
294
|
+
const reasoningParts = message.content.parts?.filter((p) => p.type === "reasoning");
|
|
295
|
+
if (reasoningParts && reasoningParts.length > 0) {
|
|
296
|
+
const reasoningTexts = reasoningParts.map((p) => {
|
|
297
|
+
if (p.details && Array.isArray(p.details)) return p.details.filter((d) => d.type === "text").map((d) => d.text).join("");
|
|
298
|
+
return p.reasoning || "";
|
|
299
|
+
}).filter(Boolean);
|
|
300
|
+
return reasoningTexts.length > 0 ? reasoningTexts.join("\n") : void 0;
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
/**
|
|
304
|
+
* Creates a tool invocation object for testing purposes.
|
|
305
|
+
*
|
|
306
|
+
* @param options - The tool invocation configuration
|
|
307
|
+
* @param options.toolCallId - Unique identifier for the tool call
|
|
308
|
+
* @param options.toolName - Name of the tool being called
|
|
309
|
+
* @param options.args - Arguments passed to the tool
|
|
310
|
+
* @param options.result - Result returned by the tool
|
|
311
|
+
* @param options.state - State of the invocation (default: 'result')
|
|
312
|
+
* @returns A tool invocation object
|
|
313
|
+
*
|
|
314
|
+
* @example
|
|
315
|
+
* ```ts
|
|
316
|
+
* const invocation = createToolInvocation({
|
|
317
|
+
* toolCallId: 'call-123',
|
|
318
|
+
* toolName: 'weatherTool',
|
|
319
|
+
* args: { location: 'London' },
|
|
320
|
+
* result: { temperature: 20, condition: 'sunny' },
|
|
321
|
+
* });
|
|
322
|
+
* ```
|
|
323
|
+
*/
|
|
324
|
+
const createToolInvocation = ({ toolCallId, toolName, args, result, state = "result" }) => {
|
|
325
|
+
return {
|
|
326
|
+
toolCallId,
|
|
327
|
+
toolName,
|
|
328
|
+
args,
|
|
329
|
+
result,
|
|
330
|
+
state
|
|
331
|
+
};
|
|
332
|
+
};
|
|
333
|
+
/**
|
|
334
|
+
* Creates a MastraDBMessage object for testing purposes.
|
|
335
|
+
*
|
|
336
|
+
* Supports optional tool invocations for testing tool call scenarios.
|
|
337
|
+
*
|
|
338
|
+
* @param options - The message configuration
|
|
339
|
+
* @param options.content - The text content of the message
|
|
340
|
+
* @param options.role - The role of the message sender ('user', 'assistant', or 'system')
|
|
341
|
+
* @param options.id - Optional message ID (default: 'test-message')
|
|
342
|
+
* @param options.toolInvocations - Optional array of tool invocations
|
|
343
|
+
* @returns A MastraDBMessage object
|
|
344
|
+
*
|
|
345
|
+
* @example
|
|
346
|
+
* ```ts
|
|
347
|
+
* const message = createTestMessage({
|
|
348
|
+
* content: 'Hello, how can I help?',
|
|
349
|
+
* role: 'assistant',
|
|
350
|
+
* });
|
|
351
|
+
*
|
|
352
|
+
* // With tool invocations
|
|
353
|
+
* const messageWithTools = createTestMessage({
|
|
354
|
+
* content: 'Let me check the weather.',
|
|
355
|
+
* role: 'assistant',
|
|
356
|
+
* toolInvocations: [{
|
|
357
|
+
* toolCallId: 'call-1',
|
|
358
|
+
* toolName: 'weatherTool',
|
|
359
|
+
* args: { location: 'Paris' },
|
|
360
|
+
* result: { temp: 22 },
|
|
361
|
+
* state: 'result',
|
|
362
|
+
* }],
|
|
363
|
+
* });
|
|
364
|
+
* ```
|
|
365
|
+
*/
|
|
366
|
+
function createTestMessage({ content, role, id = "test-message", toolInvocations = [] }) {
|
|
367
|
+
return {
|
|
368
|
+
id,
|
|
369
|
+
role,
|
|
370
|
+
content: {
|
|
371
|
+
format: 2,
|
|
372
|
+
parts: [{
|
|
373
|
+
type: "text",
|
|
374
|
+
text: content
|
|
375
|
+
}],
|
|
376
|
+
content,
|
|
377
|
+
...toolInvocations.length > 0 && { toolInvocations: toolInvocations.map((ti) => ({
|
|
378
|
+
toolCallId: ti.toolCallId,
|
|
379
|
+
toolName: ti.toolName,
|
|
380
|
+
args: ti.args,
|
|
381
|
+
result: ti.result,
|
|
382
|
+
state: ti.state
|
|
383
|
+
})) }
|
|
384
|
+
},
|
|
385
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Creates a complete agent test run object for testing scorers.
|
|
390
|
+
*
|
|
391
|
+
* Provides a convenient way to construct the full run object that scorers receive,
|
|
392
|
+
* including input messages, output, system messages, and request context.
|
|
393
|
+
*
|
|
394
|
+
* @param options - The test run configuration
|
|
395
|
+
* @param options.inputMessages - Array of input messages (default: [])
|
|
396
|
+
* @param options.output - The output messages (required)
|
|
397
|
+
* @param options.rememberedMessages - Array of remembered messages from memory (default: [])
|
|
398
|
+
* @param options.systemMessages - Array of system messages (default: [])
|
|
399
|
+
* @param options.taggedSystemMessages - Tagged system messages map (default: {})
|
|
400
|
+
* @param options.requestContext - Request context (default: new RequestContext())
|
|
401
|
+
* @param options.runId - Unique run ID (default: random UUID)
|
|
402
|
+
* @returns A complete test run object
|
|
403
|
+
*
|
|
404
|
+
* @example
|
|
405
|
+
* ```ts
|
|
406
|
+
* const testRun = createAgentTestRun({
|
|
407
|
+
* inputMessages: [createTestMessage({ content: 'Hello', role: 'user' })],
|
|
408
|
+
* output: [createTestMessage({ content: 'Hi there!', role: 'assistant' })],
|
|
409
|
+
* });
|
|
410
|
+
*
|
|
411
|
+
* const result = await scorer.run({
|
|
412
|
+
* input: testRun.input,
|
|
413
|
+
* output: testRun.output,
|
|
414
|
+
* });
|
|
415
|
+
* ```
|
|
416
|
+
*/
|
|
417
|
+
const createAgentTestRun = ({ inputMessages = [], output, rememberedMessages = [], systemMessages = [], taggedSystemMessages = {}, requestContext = new RequestContext(), runId = crypto.randomUUID() }) => {
|
|
418
|
+
return {
|
|
419
|
+
input: {
|
|
420
|
+
inputMessages,
|
|
421
|
+
rememberedMessages,
|
|
422
|
+
systemMessages,
|
|
423
|
+
taggedSystemMessages
|
|
424
|
+
},
|
|
425
|
+
output,
|
|
426
|
+
requestContext,
|
|
427
|
+
runId
|
|
428
|
+
};
|
|
429
|
+
};
|
|
430
|
+
/**
|
|
431
|
+
* Creates a test run for trajectory scorers where `output` is a `Trajectory`
|
|
432
|
+
* (pre-extracted by the `runEvals` pipeline).
|
|
433
|
+
*
|
|
434
|
+
* @example
|
|
435
|
+
* ```ts
|
|
436
|
+
* const testRun = createTrajectoryTestRun({
|
|
437
|
+
* inputMessages: [createTestMessage({ content: 'Do X', role: 'user', id: 'u1' })],
|
|
438
|
+
* trajectory: {
|
|
439
|
+
* steps: [
|
|
440
|
+
* { stepType: 'tool_call', name: 'search', toolArgs: { q: 'test' } },
|
|
441
|
+
* ],
|
|
442
|
+
* },
|
|
443
|
+
* });
|
|
444
|
+
* ```
|
|
445
|
+
*/
|
|
446
|
+
const createTrajectoryTestRun = ({ inputMessages = [], trajectory, rememberedMessages = [], systemMessages = [], taggedSystemMessages = {}, requestContext = new RequestContext(), runId = crypto.randomUUID(), expectedTrajectory }) => {
|
|
447
|
+
return {
|
|
448
|
+
input: {
|
|
449
|
+
inputMessages,
|
|
450
|
+
rememberedMessages,
|
|
451
|
+
systemMessages,
|
|
452
|
+
taggedSystemMessages
|
|
453
|
+
},
|
|
454
|
+
output: trajectory,
|
|
455
|
+
expectedTrajectory,
|
|
456
|
+
requestContext,
|
|
457
|
+
runId
|
|
458
|
+
};
|
|
459
|
+
};
|
|
460
|
+
/**
|
|
461
|
+
* Extracts all tool calls from a scorer run output.
|
|
462
|
+
*
|
|
463
|
+
* Iterates through all messages and their tool invocations to collect
|
|
464
|
+
* information about tools that were called (with state 'result' or 'call').
|
|
465
|
+
*
|
|
466
|
+
* @param output - The scorer run output (array of MastraDBMessage)
|
|
467
|
+
* @returns An object containing tool names and detailed tool call info
|
|
468
|
+
*
|
|
469
|
+
* @example
|
|
470
|
+
* ```ts
|
|
471
|
+
* const scorer = createScorer({ ... })
|
|
472
|
+
* .preprocess(({ run }) => {
|
|
473
|
+
* const { tools, toolCallInfos } = extractToolCalls(run.output);
|
|
474
|
+
* return {
|
|
475
|
+
* toolsUsed: tools,
|
|
476
|
+
* toolCount: tools.length,
|
|
477
|
+
* };
|
|
478
|
+
* });
|
|
479
|
+
* ```
|
|
480
|
+
*/
|
|
481
|
+
function extractToolCalls(output) {
|
|
482
|
+
const toolCalls = [];
|
|
483
|
+
const toolCallInfos = [];
|
|
484
|
+
for (let messageIndex = 0; messageIndex < output.length; messageIndex++) {
|
|
485
|
+
const message = output[messageIndex];
|
|
486
|
+
const legacy = message?.content?.toolInvocations;
|
|
487
|
+
const fromParts = legacy ? void 0 : message?.content?.parts?.filter((p) => p.type === "tool-invocation").map((p) => p.toolInvocation);
|
|
488
|
+
const toolInvocations = legacy ?? fromParts;
|
|
489
|
+
if (!toolInvocations?.length) continue;
|
|
490
|
+
for (let invocationIndex = 0; invocationIndex < toolInvocations.length; invocationIndex++) {
|
|
491
|
+
const invocation = toolInvocations[invocationIndex];
|
|
492
|
+
if (invocation && invocation.toolName && (invocation.state === "result" || invocation.state === "call")) {
|
|
493
|
+
toolCalls.push(invocation.toolName);
|
|
494
|
+
toolCallInfos.push({
|
|
495
|
+
toolName: invocation.toolName,
|
|
496
|
+
toolCallId: invocation.toolCallId || `${messageIndex}-${invocationIndex}`,
|
|
497
|
+
messageIndex,
|
|
498
|
+
invocationIndex
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return {
|
|
504
|
+
tools: toolCalls,
|
|
505
|
+
toolCallInfos
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Extracts text content from all input messages.
|
|
510
|
+
*
|
|
511
|
+
* @param runInput - The scorer run input
|
|
512
|
+
* @returns An array of text strings from each input message
|
|
513
|
+
*
|
|
514
|
+
* @example
|
|
515
|
+
* ```ts
|
|
516
|
+
* const scorer = createScorer({ ... })
|
|
517
|
+
* .preprocess(({ run }) => {
|
|
518
|
+
* const messages = extractInputMessages(run.input);
|
|
519
|
+
* return { allUserMessages: messages.join('\n') };
|
|
520
|
+
* });
|
|
521
|
+
* ```
|
|
522
|
+
*/
|
|
523
|
+
const extractInputMessages = (runInput) => {
|
|
524
|
+
return runInput?.inputMessages?.map((msg) => getTextContentFromMastraDBMessage(msg)) || [];
|
|
525
|
+
};
|
|
526
|
+
/**
|
|
527
|
+
* Extracts text content from all assistant response messages.
|
|
528
|
+
*
|
|
529
|
+
* Filters for messages with role 'assistant' and extracts their text content.
|
|
530
|
+
*
|
|
531
|
+
* @param runOutput - The scorer run output (array of MastraDBMessage)
|
|
532
|
+
* @returns An array of text strings from each assistant message
|
|
533
|
+
*
|
|
534
|
+
* @example
|
|
535
|
+
* ```ts
|
|
536
|
+
* const scorer = createScorer({ ... })
|
|
537
|
+
* .preprocess(({ run }) => {
|
|
538
|
+
* const responses = extractAgentResponseMessages(run.output);
|
|
539
|
+
* return { allResponses: responses.join('\n') };
|
|
540
|
+
* });
|
|
541
|
+
* ```
|
|
542
|
+
*/
|
|
543
|
+
const extractAgentResponseMessages = (runOutput) => {
|
|
544
|
+
return runOutput.filter((msg) => msg.role === "assistant").map((msg) => getTextContentFromMastraDBMessage(msg));
|
|
545
|
+
};
|
|
546
|
+
/**
|
|
547
|
+
* Extracts tool results from a scorer run output.
|
|
548
|
+
*
|
|
549
|
+
* Returns structured objects that can be used with the hallucination scorer's
|
|
550
|
+
* `getContext` hook or for other scorer logic.
|
|
551
|
+
*
|
|
552
|
+
* @param output - The scorer run output (array of MastraDBMessage)
|
|
553
|
+
* @returns An array of ToolResultInfo objects
|
|
554
|
+
*
|
|
555
|
+
* @example
|
|
556
|
+
* ```ts
|
|
557
|
+
* import { extractToolResults } from '@mastra/evals/scorers';
|
|
558
|
+
* import { createHallucinationScorer } from '@mastra/evals/scorers/prebuilt';
|
|
559
|
+
*
|
|
560
|
+
* const scorer = createHallucinationScorer({
|
|
561
|
+
* model: openai('gpt-4o'),
|
|
562
|
+
* options: {
|
|
563
|
+
* getContext: (run) => {
|
|
564
|
+
* const toolResults = extractToolResults(run.output);
|
|
565
|
+
* return toolResults.map(t => JSON.stringify({ tool: t.toolName, result: t.result }));
|
|
566
|
+
* },
|
|
567
|
+
* },
|
|
568
|
+
* });
|
|
569
|
+
* ```
|
|
570
|
+
*/
|
|
571
|
+
function extractToolResults(output) {
|
|
572
|
+
const results = [];
|
|
573
|
+
for (const message of output) {
|
|
574
|
+
const legacy = message?.content?.toolInvocations;
|
|
575
|
+
const fromParts = legacy ? void 0 : message?.content?.parts?.filter((p) => p.type === "tool-invocation").map((p) => p.toolInvocation);
|
|
576
|
+
const toolInvocations = legacy ?? fromParts;
|
|
577
|
+
if (!toolInvocations?.length) continue;
|
|
578
|
+
for (const invocation of toolInvocations) if (invocation.state === "result" && invocation.result !== void 0) results.push({
|
|
579
|
+
toolName: invocation.toolName,
|
|
580
|
+
toolCallId: invocation.toolCallId || "",
|
|
581
|
+
args: invocation.args || {},
|
|
582
|
+
result: invocation.result
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
return results;
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Compares two trajectories and returns detailed comparison results.
|
|
589
|
+
*
|
|
590
|
+
* This is the core comparison logic used by trajectory scorers. It supports
|
|
591
|
+
* strict and non-strict ordering, optional step data comparison, and loop detection.
|
|
592
|
+
*
|
|
593
|
+
* @param actual - The trajectory the agent actually took
|
|
594
|
+
* @param expected - The expected trajectory to compare against
|
|
595
|
+
* @param options - Comparison configuration options
|
|
596
|
+
* @returns Detailed comparison results including match scores and diagnostics
|
|
597
|
+
*
|
|
598
|
+
* @example
|
|
599
|
+
* ```ts
|
|
600
|
+
* const result = compareTrajectories(
|
|
601
|
+
* { steps: [{ stepType: 'tool_call', name: 'search' }, { stepType: 'tool_call', name: 'summarize' }] },
|
|
602
|
+
* { steps: [{ stepType: 'tool_call', name: 'search' }, { stepType: 'tool_call', name: 'summarize' }] },
|
|
603
|
+
* { ordering: 'strict' }
|
|
604
|
+
* );
|
|
605
|
+
* // result.score = 1.0
|
|
606
|
+
* ```
|
|
607
|
+
*/
|
|
608
|
+
function compareTrajectories(actual, expected, options = {}) {
|
|
609
|
+
const { allowRepeatedSteps = true, ordering = "relaxed" } = options;
|
|
610
|
+
const normalizedExpected = { steps: expected.steps };
|
|
611
|
+
if (normalizedExpected.steps.length === 0) return {
|
|
612
|
+
score: actual.steps.length === 0 ? 1 : 0,
|
|
613
|
+
matchedSteps: 0,
|
|
614
|
+
totalExpectedSteps: 0,
|
|
615
|
+
totalActualSteps: actual.steps.length,
|
|
616
|
+
missingSteps: [],
|
|
617
|
+
extraSteps: actual.steps.map((s) => s.name),
|
|
618
|
+
outOfOrderSteps: [],
|
|
619
|
+
repeatedSteps: []
|
|
620
|
+
};
|
|
621
|
+
const actualNames = actual.steps.map((s) => s.name);
|
|
622
|
+
const nameCounts = /* @__PURE__ */ new Map();
|
|
623
|
+
for (const name of actualNames) nameCounts.set(name, (nameCounts.get(name) || 0) + 1);
|
|
624
|
+
const repeatedSteps = [...nameCounts.entries()].filter(([_, count]) => count > 1).map(([name]) => name);
|
|
625
|
+
if (ordering === "strict") return compareStrictOrder(actual, normalizedExpected, {
|
|
626
|
+
allowRepeatedSteps,
|
|
627
|
+
repeatedSteps
|
|
628
|
+
});
|
|
629
|
+
if (ordering === "unordered") return compareUnorderedPresence(actual, normalizedExpected, {
|
|
630
|
+
allowRepeatedSteps,
|
|
631
|
+
repeatedSteps
|
|
632
|
+
});
|
|
633
|
+
return compareRelaxedOrder(actual, normalizedExpected, {
|
|
634
|
+
allowRepeatedSteps,
|
|
635
|
+
repeatedSteps
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
function compareStrictOrder(actual, expected, opts) {
|
|
639
|
+
const actualNames = actual.steps.map((s) => s.name);
|
|
640
|
+
const expectedNames = expected.steps.map((s) => s.name);
|
|
641
|
+
let matchedSteps = 0;
|
|
642
|
+
const outOfOrderSteps = [];
|
|
643
|
+
const matchedExpectedIndices = /* @__PURE__ */ new Set();
|
|
644
|
+
const maxLen = Math.max(actualNames.length, expectedNames.length);
|
|
645
|
+
for (let i = 0; i < maxLen; i++) {
|
|
646
|
+
const actualName = actualNames[i];
|
|
647
|
+
if (actualName === expectedNames[i]) if (actual.steps[i] && expected.steps[i]) {
|
|
648
|
+
if (expectedStepMatches(actual.steps[i], expected.steps[i])) {
|
|
649
|
+
matchedSteps++;
|
|
650
|
+
matchedExpectedIndices.add(i);
|
|
651
|
+
}
|
|
652
|
+
} else {
|
|
653
|
+
matchedSteps++;
|
|
654
|
+
matchedExpectedIndices.add(i);
|
|
655
|
+
}
|
|
656
|
+
else if (actualName && expectedNames.includes(actualName)) outOfOrderSteps.push(actualName);
|
|
657
|
+
}
|
|
658
|
+
const missingSteps = expectedNames.filter((_, i) => !matchedExpectedIndices.has(i));
|
|
659
|
+
const extraSteps = actualNames.filter((name) => !expectedNames.includes(name));
|
|
660
|
+
let score = matchedSteps / expected.steps.length;
|
|
661
|
+
if (actualNames.length > expectedNames.length) {
|
|
662
|
+
const extraPenalty = (actualNames.length - expectedNames.length) / expectedNames.length;
|
|
663
|
+
score = Math.max(0, score - extraPenalty * .5);
|
|
664
|
+
}
|
|
665
|
+
if (!opts.allowRepeatedSteps && opts.repeatedSteps.length > 0) score = Math.max(0, score - opts.repeatedSteps.length * .1);
|
|
666
|
+
return {
|
|
667
|
+
score: roundToTwoDecimals(Math.max(0, Math.min(1, score))),
|
|
668
|
+
matchedSteps,
|
|
669
|
+
totalExpectedSteps: expected.steps.length,
|
|
670
|
+
totalActualSteps: actual.steps.length,
|
|
671
|
+
missingSteps,
|
|
672
|
+
extraSteps,
|
|
673
|
+
outOfOrderSteps,
|
|
674
|
+
repeatedSteps: opts.repeatedSteps
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
function compareRelaxedOrder(actual, expected, opts) {
|
|
678
|
+
const actualNames = actual.steps.map((s) => s.name);
|
|
679
|
+
const expectedNames = expected.steps.map((s) => s.name);
|
|
680
|
+
let matchedSteps = 0;
|
|
681
|
+
let lastMatchedIndex = -1;
|
|
682
|
+
const outOfOrderSteps = [];
|
|
683
|
+
const matchedExpectedIndices = /* @__PURE__ */ new Set();
|
|
684
|
+
for (let i = 0; i < expectedNames.length; i++) {
|
|
685
|
+
const expectedName = expectedNames[i];
|
|
686
|
+
let found = false;
|
|
687
|
+
for (let j = lastMatchedIndex + 1; j < actualNames.length; j++) if (actualNames[j] === expectedName) if (actual.steps[j] && expected.steps[i]) {
|
|
688
|
+
if (expectedStepMatches(actual.steps[j], expected.steps[i])) {
|
|
689
|
+
matchedSteps++;
|
|
690
|
+
lastMatchedIndex = j;
|
|
691
|
+
matchedExpectedIndices.add(i);
|
|
692
|
+
found = true;
|
|
693
|
+
break;
|
|
694
|
+
}
|
|
695
|
+
} else {
|
|
696
|
+
matchedSteps++;
|
|
697
|
+
lastMatchedIndex = j;
|
|
698
|
+
matchedExpectedIndices.add(i);
|
|
699
|
+
found = true;
|
|
700
|
+
break;
|
|
701
|
+
}
|
|
702
|
+
if (!found) {
|
|
703
|
+
if (actualNames.includes(expectedName)) outOfOrderSteps.push(expectedName);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
const missingSteps = expectedNames.filter((_, i) => !matchedExpectedIndices.has(i));
|
|
707
|
+
const expectedSet = new Set(expectedNames);
|
|
708
|
+
const extraSteps = actualNames.filter((name) => !expectedSet.has(name));
|
|
709
|
+
let score = matchedSteps / expected.steps.length;
|
|
710
|
+
if (!opts.allowRepeatedSteps && opts.repeatedSteps.length > 0) score = Math.max(0, score - opts.repeatedSteps.length * .1);
|
|
711
|
+
return {
|
|
712
|
+
score: roundToTwoDecimals(Math.max(0, Math.min(1, score))),
|
|
713
|
+
matchedSteps,
|
|
714
|
+
totalExpectedSteps: expected.steps.length,
|
|
715
|
+
totalActualSteps: actual.steps.length,
|
|
716
|
+
missingSteps,
|
|
717
|
+
extraSteps,
|
|
718
|
+
outOfOrderSteps,
|
|
719
|
+
repeatedSteps: opts.repeatedSteps
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Fields on each ExpectedStep variant that are comparable data (not structural).
|
|
724
|
+
* Used by `expectedStepMatches` to know which fields to compare when `compareData` is true.
|
|
725
|
+
*/
|
|
726
|
+
const COMPARABLE_FIELDS_BY_TYPE = {
|
|
727
|
+
tool_call: [
|
|
728
|
+
"toolArgs",
|
|
729
|
+
"toolResult",
|
|
730
|
+
"success"
|
|
731
|
+
],
|
|
732
|
+
mcp_tool_call: [
|
|
733
|
+
"toolArgs",
|
|
734
|
+
"toolResult",
|
|
735
|
+
"mcpServer",
|
|
736
|
+
"success"
|
|
737
|
+
],
|
|
738
|
+
model_generation: [
|
|
739
|
+
"modelId",
|
|
740
|
+
"promptTokens",
|
|
741
|
+
"completionTokens",
|
|
742
|
+
"finishReason"
|
|
743
|
+
],
|
|
744
|
+
agent_run: ["agentId"],
|
|
745
|
+
workflow_step: [
|
|
746
|
+
"stepId",
|
|
747
|
+
"status",
|
|
748
|
+
"output"
|
|
749
|
+
],
|
|
750
|
+
workflow_run: ["workflowId", "status"],
|
|
751
|
+
workflow_conditional: ["conditionCount", "selectedSteps"],
|
|
752
|
+
workflow_parallel: ["branchCount", "parallelSteps"],
|
|
753
|
+
workflow_loop: ["loopType", "totalIterations"],
|
|
754
|
+
workflow_sleep: ["sleepDurationMs", "sleepType"],
|
|
755
|
+
workflow_wait_event: ["eventName", "eventReceived"],
|
|
756
|
+
processor_run: ["processorId"]
|
|
757
|
+
};
|
|
758
|
+
/**
|
|
759
|
+
* Check if an actual TrajectoryStep matches an ExpectedStep.
|
|
760
|
+
* Matches by name, optionally by stepType, and auto-compares any variant-specific
|
|
761
|
+
* fields that are present on the expected step.
|
|
762
|
+
*/
|
|
763
|
+
function expectedStepMatches(actual, expected) {
|
|
764
|
+
if (actual.name !== expected.name) return false;
|
|
765
|
+
if (expected.stepType && actual.stepType !== expected.stepType) return false;
|
|
766
|
+
if (expected.stepType) {
|
|
767
|
+
const fields = COMPARABLE_FIELDS_BY_TYPE[expected.stepType] ?? [];
|
|
768
|
+
for (const field of fields) {
|
|
769
|
+
const expectedVal = expected[field];
|
|
770
|
+
if (expectedVal === void 0) continue;
|
|
771
|
+
const actualVal = actual[field];
|
|
772
|
+
if (actualVal === void 0) return false;
|
|
773
|
+
try {
|
|
774
|
+
if (JSON.stringify(actualVal) !== JSON.stringify(expectedVal)) return false;
|
|
775
|
+
} catch {
|
|
776
|
+
return false;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
return true;
|
|
781
|
+
}
|
|
782
|
+
function compareUnorderedPresence(actual, expected, opts) {
|
|
783
|
+
const actualNames = actual.steps.map((s) => s.name);
|
|
784
|
+
const expectedNames = expected.steps.map((s) => s.name);
|
|
785
|
+
let matchedSteps = 0;
|
|
786
|
+
const matchedExpectedIndices = /* @__PURE__ */ new Set();
|
|
787
|
+
const usedIndices = /* @__PURE__ */ new Set();
|
|
788
|
+
for (let i = 0; i < expected.steps.length; i++) {
|
|
789
|
+
const expectedStep = expected.steps[i];
|
|
790
|
+
for (let j = 0; j < actual.steps.length; j++) if (!usedIndices.has(j) && expectedStepMatches(actual.steps[j], expectedStep)) {
|
|
791
|
+
matchedSteps++;
|
|
792
|
+
matchedExpectedIndices.add(i);
|
|
793
|
+
usedIndices.add(j);
|
|
794
|
+
break;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
const missingSteps = expectedNames.filter((_, i) => !matchedExpectedIndices.has(i));
|
|
798
|
+
const expectedSet = new Set(expectedNames);
|
|
799
|
+
const extraSteps = actualNames.filter((name) => !expectedSet.has(name));
|
|
800
|
+
let score = matchedSteps / expected.steps.length;
|
|
801
|
+
if (!opts.allowRepeatedSteps && opts.repeatedSteps.length > 0) score = Math.max(0, score - opts.repeatedSteps.length * .1);
|
|
802
|
+
return {
|
|
803
|
+
score: roundToTwoDecimals(Math.max(0, Math.min(1, score))),
|
|
804
|
+
matchedSteps,
|
|
805
|
+
totalExpectedSteps: expected.steps.length,
|
|
806
|
+
totalActualSteps: actual.steps.length,
|
|
807
|
+
missingSteps,
|
|
808
|
+
extraSteps,
|
|
809
|
+
outOfOrderSteps: [],
|
|
810
|
+
repeatedSteps: opts.repeatedSteps
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Evaluate trajectory efficiency against budgets and redundancy checks.
|
|
815
|
+
*/
|
|
816
|
+
function checkTrajectoryEfficiency(trajectory, options = {}) {
|
|
817
|
+
const { maxSteps, maxTotalTokens, maxTotalDurationMs, noRedundantCalls = true } = options;
|
|
818
|
+
const totalSteps = trajectory.steps.length;
|
|
819
|
+
let totalTokens = 0;
|
|
820
|
+
for (const step of trajectory.steps) if (step.stepType === "model_generation") totalTokens += (step.promptTokens ?? 0) + (step.completionTokens ?? 0);
|
|
821
|
+
const totalDurationMs = trajectory.totalDurationMs ?? trajectory.steps.reduce((sum, s) => sum + (s.durationMs ?? 0), 0);
|
|
822
|
+
const redundantCalls = [];
|
|
823
|
+
if (noRedundantCalls) for (let i = 1; i < trajectory.steps.length; i++) {
|
|
824
|
+
const prev = trajectory.steps[i - 1];
|
|
825
|
+
const curr = trajectory.steps[i];
|
|
826
|
+
if (prev.name === curr.name && prev.stepType === curr.stepType && (prev.stepType === "tool_call" || prev.stepType === "mcp_tool_call")) {
|
|
827
|
+
const prevArgs = prev.toolArgs;
|
|
828
|
+
const currArgs = curr.toolArgs;
|
|
829
|
+
try {
|
|
830
|
+
if (JSON.stringify(prevArgs) === JSON.stringify(currArgs)) redundantCalls.push({
|
|
831
|
+
name: curr.name,
|
|
832
|
+
index: i
|
|
833
|
+
});
|
|
834
|
+
} catch {}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
const overStepBudget = maxSteps !== void 0 && totalSteps > maxSteps;
|
|
838
|
+
const overTokenBudget = maxTotalTokens !== void 0 && totalTokens > maxTotalTokens;
|
|
839
|
+
const overDurationBudget = maxTotalDurationMs !== void 0 && totalDurationMs > maxTotalDurationMs;
|
|
840
|
+
const dimensions = [];
|
|
841
|
+
if (maxSteps !== void 0) dimensions.push(overStepBudget ? Math.max(0, 1 - (totalSteps - maxSteps) / maxSteps) : 1);
|
|
842
|
+
if (maxTotalTokens !== void 0) dimensions.push(overTokenBudget ? Math.max(0, 1 - (totalTokens - maxTotalTokens) / maxTotalTokens) : 1);
|
|
843
|
+
if (maxTotalDurationMs !== void 0) dimensions.push(overDurationBudget ? Math.max(0, 1 - (totalDurationMs - maxTotalDurationMs) / maxTotalDurationMs) : 1);
|
|
844
|
+
if (noRedundantCalls) dimensions.push(redundantCalls.length === 0 ? 1 : Math.max(0, 1 - redundantCalls.length * .2));
|
|
845
|
+
const score = dimensions.length > 0 ? dimensions.reduce((a, b) => a + b, 0) / dimensions.length : 1;
|
|
846
|
+
return {
|
|
847
|
+
score: roundToTwoDecimals(Math.max(0, Math.min(1, score))),
|
|
848
|
+
totalSteps,
|
|
849
|
+
overStepBudget,
|
|
850
|
+
totalTokens,
|
|
851
|
+
overTokenBudget,
|
|
852
|
+
totalDurationMs,
|
|
853
|
+
overDurationBudget,
|
|
854
|
+
redundantCalls
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* Check if a trajectory violates any blacklist rules.
|
|
859
|
+
* Returns score 0.0 if any violation is found (hard fail).
|
|
860
|
+
*/
|
|
861
|
+
function checkTrajectoryBlacklist(trajectory, options = {}) {
|
|
862
|
+
const { blacklistedTools = [], blacklistedSequences = [] } = options;
|
|
863
|
+
const violatedTools = [];
|
|
864
|
+
const violatedSequences = [];
|
|
865
|
+
const stepNames = trajectory.steps.map((s) => s.name);
|
|
866
|
+
for (const forbidden of blacklistedTools) if (stepNames.includes(forbidden)) violatedTools.push(forbidden);
|
|
867
|
+
for (const sequence of blacklistedSequences) {
|
|
868
|
+
if (sequence.length === 0) continue;
|
|
869
|
+
for (let i = 0; i <= stepNames.length - sequence.length; i++) {
|
|
870
|
+
let match = true;
|
|
871
|
+
for (let j = 0; j < sequence.length; j++) if (stepNames[i + j] !== sequence[j]) {
|
|
872
|
+
match = false;
|
|
873
|
+
break;
|
|
874
|
+
}
|
|
875
|
+
if (match) {
|
|
876
|
+
violatedSequences.push(sequence);
|
|
877
|
+
break;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return {
|
|
882
|
+
score: violatedTools.length > 0 || violatedSequences.length > 0 ? 0 : 1,
|
|
883
|
+
violatedTools,
|
|
884
|
+
violatedSequences
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* Analyze tool failure and retry patterns in a trajectory.
|
|
889
|
+
*/
|
|
890
|
+
function analyzeToolFailures(trajectory, options = {}) {
|
|
891
|
+
const { maxRetriesPerTool = 2 } = options;
|
|
892
|
+
const patterns = [];
|
|
893
|
+
let totalRetries = 0;
|
|
894
|
+
const toolCallSteps = trajectory.steps.filter((s) => s.stepType === "tool_call" || s.stepType === "mcp_tool_call");
|
|
895
|
+
if (toolCallSteps.length === 0) return {
|
|
896
|
+
score: 1,
|
|
897
|
+
patterns: [],
|
|
898
|
+
totalRetries: 0,
|
|
899
|
+
excessiveRetryTools: []
|
|
900
|
+
};
|
|
901
|
+
let i = 0;
|
|
902
|
+
while (i < toolCallSteps.length) {
|
|
903
|
+
const currentTool = toolCallSteps[i];
|
|
904
|
+
let retryCount = 0;
|
|
905
|
+
let j = i + 1;
|
|
906
|
+
while (j < toolCallSteps.length && toolCallSteps[j].name === currentTool.name) {
|
|
907
|
+
if (toolCallSteps[j - 1].success === false) retryCount++;
|
|
908
|
+
j++;
|
|
909
|
+
}
|
|
910
|
+
if (retryCount > 0) {
|
|
911
|
+
const nextDifferentTool = j < toolCallSteps.length ? toolCallSteps[j] : void 0;
|
|
912
|
+
const lastSuccess = toolCallSteps[j - 1].success !== false;
|
|
913
|
+
patterns.push({
|
|
914
|
+
toolName: currentTool.name,
|
|
915
|
+
retryCount,
|
|
916
|
+
fellBackToAlternative: nextDifferentTool !== void 0 && !lastSuccess,
|
|
917
|
+
alternativeTool: nextDifferentTool !== void 0 && !lastSuccess ? nextDifferentTool.name : void 0,
|
|
918
|
+
eventuallySucceeded: lastSuccess
|
|
919
|
+
});
|
|
920
|
+
totalRetries += retryCount;
|
|
921
|
+
}
|
|
922
|
+
i = j;
|
|
923
|
+
}
|
|
924
|
+
const excessiveRetryTools = patterns.filter((p) => p.retryCount > maxRetriesPerTool).map((p) => p.toolName);
|
|
925
|
+
let score = 1;
|
|
926
|
+
if (toolCallSteps.length > 0) {
|
|
927
|
+
const excessRetries = patterns.reduce((sum, p) => sum + Math.max(0, p.retryCount - maxRetriesPerTool), 0);
|
|
928
|
+
score = Math.max(0, 1 - excessRetries * .2);
|
|
929
|
+
}
|
|
930
|
+
return {
|
|
931
|
+
score: roundToTwoDecimals(Math.max(0, Math.min(1, score))),
|
|
932
|
+
patterns,
|
|
933
|
+
totalRetries,
|
|
934
|
+
excessiveRetryTools
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
//#endregion
|
|
938
|
+
export { analyzeToolFailures, checkTrajectoryBlacklist, checkTrajectoryEfficiency, compareTrajectories, createAgentTestRun, createTestMessage, createTestRun, createToolInvocation, createTrajectoryTestRun, extractAgentResponseMessages, extractInputMessages, extractToolCalls, extractToolResults, extractTrajectory, getAssistantMessageFromRunOutput, getCombinedSystemPrompt, getReasoningFromRunOutput, getSystemMessagesFromRunInput, getTextContentFromMastraDBMessage, getUserMessageFromRunInput, isCloserTo, isScorerRunInputForAgent, isScorerRunOutputForAgent, roundToTwoDecimals };
|
|
939
|
+
|
|
3
940
|
//# sourceMappingURL=utils.js.map
|