@botpress/evals 1.18.3 → 1.19.0-beta.10
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/dist/client.d.ts +11 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +158 -26
- package/dist/client.js.map +3 -3
- package/dist/errors.d.ts +15 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/graders/index.js +17 -7
- package/dist/graders/index.js.map +3 -3
- package/dist/graders/llm.d.ts +2 -0
- package/dist/graders/llm.d.ts.map +1 -1
- package/dist/graders/llm.js +24 -7
- package/dist/graders/llm.js.map +3 -3
- package/dist/graders/response.js +15 -6
- package/dist/graders/response.js.map +3 -3
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +60 -8
- package/dist/loader.js.map +3 -3
- package/dist/runner.d.ts +2 -0
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +308 -80
- package/dist/runner.js.map +3 -3
- package/dist/sse-collector.d.ts +7 -0
- package/dist/sse-collector.d.ts.map +1 -1
- package/dist/sse-collector.js +98 -3
- package/dist/sse-collector.js.map +3 -3
- package/dist/transformer.d.ts.map +1 -1
- package/dist/transformer.js.map +2 -2
- package/dist/types.d.ts +17 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +5 -1
- package/dist/types.js.map +3 -3
- package/package.json +5 -11
package/dist/runner.js
CHANGED
|
@@ -1,9 +1,85 @@
|
|
|
1
1
|
// src/runner.ts
|
|
2
2
|
import { Client as BpClient2 } from "@botpress/client";
|
|
3
3
|
|
|
4
|
+
// src/definition.ts
|
|
5
|
+
var Eval = class {
|
|
6
|
+
name;
|
|
7
|
+
description;
|
|
8
|
+
tags;
|
|
9
|
+
type;
|
|
10
|
+
setup;
|
|
11
|
+
conversation;
|
|
12
|
+
outcome;
|
|
13
|
+
options;
|
|
14
|
+
constructor(def) {
|
|
15
|
+
this.name = def.name;
|
|
16
|
+
this.conversation = def.conversation;
|
|
17
|
+
if (def.description !== void 0) this.description = def.description;
|
|
18
|
+
if (def.tags !== void 0) this.tags = def.tags;
|
|
19
|
+
if (def.type !== void 0) this.type = def.type;
|
|
20
|
+
if (def.setup !== void 0) this.setup = def.setup;
|
|
21
|
+
if (def.outcome !== void 0) this.outcome = def.outcome;
|
|
22
|
+
if (def.options !== void 0) this.options = def.options;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// src/types.ts
|
|
27
|
+
var defaultLogger = console;
|
|
28
|
+
|
|
4
29
|
// src/client.ts
|
|
5
30
|
import { Client as BpClient } from "@botpress/client";
|
|
6
31
|
import { createRequire } from "module";
|
|
32
|
+
|
|
33
|
+
// ../analytics/src/errors.ts
|
|
34
|
+
var AdkError = class extends Error {
|
|
35
|
+
/**
|
|
36
|
+
* Marker for cross-module-instance checks — `instanceof` breaks when a
|
|
37
|
+
* package is duplicated in the dependency graph.
|
|
38
|
+
* @internal
|
|
39
|
+
*/
|
|
40
|
+
static __IS_ADK_BASE_ERROR = true;
|
|
41
|
+
/**
|
|
42
|
+
* Stable SCREAMING_SNAKE_CASE identifier, e.g. 'BOT_NOT_FOUND'. Subclasses
|
|
43
|
+
* pin `Code` to a literal union (`class EvalRunnerError extends
|
|
44
|
+
* AdkError<EvalErrorCode>`) so typos are compile errors and `code` narrows
|
|
45
|
+
* in switches.
|
|
46
|
+
*/
|
|
47
|
+
code;
|
|
48
|
+
/**
|
|
49
|
+
* True for user/environment conditions the developer can fix themselves
|
|
50
|
+
* (validation, auth, connectivity). False for internal bugs. Defaults to
|
|
51
|
+
* false so unclassified errors surface as bugs until someone triages them.
|
|
52
|
+
*/
|
|
53
|
+
expected;
|
|
54
|
+
/** Structured context. Sanitized before any telemetry leaves the machine. */
|
|
55
|
+
details;
|
|
56
|
+
/** Actionable next step shown to the user, e.g. "Run 'adk login' first." */
|
|
57
|
+
suggestion;
|
|
58
|
+
constructor(opts) {
|
|
59
|
+
super(opts.message, opts.cause !== void 0 ? { cause: opts.cause } : void 0);
|
|
60
|
+
this.name = this.constructor.name;
|
|
61
|
+
this.code = opts.code;
|
|
62
|
+
this.expected = opts.expected ?? false;
|
|
63
|
+
if (opts.details !== void 0) {
|
|
64
|
+
this.details = opts.details;
|
|
65
|
+
}
|
|
66
|
+
if (opts.suggestion !== void 0) {
|
|
67
|
+
this.suggestion = opts.suggestion;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
function isAdkError(error) {
|
|
72
|
+
if (error instanceof AdkError) return true;
|
|
73
|
+
if (!(error instanceof Error)) return false;
|
|
74
|
+
return error.constructor?.__IS_ADK_BASE_ERROR === true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/errors.ts
|
|
78
|
+
var EvalRunnerError = class extends AdkError {
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// src/client.ts
|
|
82
|
+
var TRACE_STREAM_PREFLIGHT_TIMEOUT_MS = 1e4;
|
|
7
83
|
var defaultChatClient = null;
|
|
8
84
|
function getDefaultChatClient() {
|
|
9
85
|
if (defaultChatClient) return defaultChatClient;
|
|
@@ -11,7 +87,10 @@ function getDefaultChatClient() {
|
|
|
11
87
|
const mod = require2("@botpress/chat");
|
|
12
88
|
const ChatClient = mod.default?.Client ?? mod.Client;
|
|
13
89
|
if (!ChatClient) {
|
|
14
|
-
throw new
|
|
90
|
+
throw new EvalRunnerError({
|
|
91
|
+
code: "CHAT_CLIENT_MISSING",
|
|
92
|
+
message: "@botpress/chat did not expose a Client export. Pass EvalRunnerConfig.chatClient explicitly."
|
|
93
|
+
});
|
|
15
94
|
}
|
|
16
95
|
return ChatClient;
|
|
17
96
|
}
|
|
@@ -26,18 +105,23 @@ var ChatSession = class {
|
|
|
26
105
|
const ChatClient = this.chatClient ?? getDefaultChatClient();
|
|
27
106
|
this.client = await ChatClient.connect({ webhookId: this.webhookId });
|
|
28
107
|
}
|
|
29
|
-
|
|
108
|
+
/** Invariant guard — calling any session method before connect() is a runner bug. */
|
|
109
|
+
assertConnected() {
|
|
30
110
|
if (!this.client) {
|
|
31
|
-
throw new
|
|
111
|
+
throw new EvalRunnerError({
|
|
112
|
+
code: "CHAT_NOT_CONNECTED",
|
|
113
|
+
message: "ChatSession not connected. Call connect() first."
|
|
114
|
+
});
|
|
32
115
|
}
|
|
33
|
-
return this.client
|
|
116
|
+
return this.client;
|
|
117
|
+
}
|
|
118
|
+
get userId() {
|
|
119
|
+
return this.assertConnected().user.id;
|
|
34
120
|
}
|
|
35
121
|
async ensureConversation() {
|
|
36
|
-
|
|
37
|
-
throw new Error("ChatSession not connected. Call connect() first.");
|
|
38
|
-
}
|
|
122
|
+
const client = this.assertConnected();
|
|
39
123
|
if (!this.conversationId) {
|
|
40
|
-
const conv = await
|
|
124
|
+
const conv = await client.createConversation({});
|
|
41
125
|
this.conversationId = conv.conversation.id;
|
|
42
126
|
return conv.conversation.id;
|
|
43
127
|
}
|
|
@@ -47,21 +131,17 @@ var ChatSession = class {
|
|
|
47
131
|
* Send a user message. Fire-and-forget — observation is via SSECollector.
|
|
48
132
|
*/
|
|
49
133
|
async sendMessage(message) {
|
|
50
|
-
|
|
51
|
-
throw new Error("ChatSession not connected. Call connect() first.");
|
|
52
|
-
}
|
|
134
|
+
const client = this.assertConnected();
|
|
53
135
|
const conversationId = await this.ensureConversation();
|
|
54
|
-
await
|
|
136
|
+
await client.createMessage({ conversationId, payload: { type: "text", text: message } });
|
|
55
137
|
}
|
|
56
138
|
/**
|
|
57
139
|
* Send an event. Fire-and-forget — observation is via SSECollector.
|
|
58
140
|
*/
|
|
59
141
|
async sendEvent(type, payload) {
|
|
60
|
-
|
|
61
|
-
throw new Error("ChatSession not connected. Call connect() first.");
|
|
62
|
-
}
|
|
142
|
+
const client = this.assertConnected();
|
|
63
143
|
const conversationId = await this.ensureConversation();
|
|
64
|
-
await
|
|
144
|
+
await client.createEvent({ type, payload, conversationId });
|
|
65
145
|
}
|
|
66
146
|
};
|
|
67
147
|
async function discoverWebhookId(botId, token, apiUrl) {
|
|
@@ -71,26 +151,102 @@ async function discoverWebhookId(botId, token, apiUrl) {
|
|
|
71
151
|
const chat = Object.values(integrations).find((int) => int.name === "chat");
|
|
72
152
|
const webhookId = chat?.webhookId;
|
|
73
153
|
if (!webhookId || typeof webhookId !== "string") {
|
|
74
|
-
throw new
|
|
75
|
-
|
|
154
|
+
throw new EvalRunnerError({
|
|
155
|
+
code: "CHAT_INTEGRATION_MISSING",
|
|
156
|
+
message: [
|
|
76
157
|
"The `chat` integration is not installed on this bot \u2014 evals require it.",
|
|
77
158
|
"",
|
|
78
159
|
"To fix:",
|
|
79
160
|
" 1. Install it: adk add chat",
|
|
80
161
|
" 2. Redeploy: adk dev (or: adk deploy)"
|
|
81
|
-
].join("\n")
|
|
82
|
-
|
|
162
|
+
].join("\n"),
|
|
163
|
+
expected: true
|
|
164
|
+
});
|
|
83
165
|
}
|
|
84
166
|
return webhookId;
|
|
85
167
|
}
|
|
86
|
-
async function
|
|
168
|
+
async function assertTraceStreamReadable(devServerUrl, headers = {}, timeoutMs = TRACE_STREAM_PREFLIGHT_TIMEOUT_MS) {
|
|
169
|
+
const url = `${devServerUrl.replace(/\/$/, "")}/api/traces/stream?count=1`;
|
|
170
|
+
const abortController = new AbortController();
|
|
171
|
+
let timedOut = false;
|
|
172
|
+
let reader = null;
|
|
173
|
+
const timer = setTimeout(() => {
|
|
174
|
+
timedOut = true;
|
|
175
|
+
abortController.abort();
|
|
176
|
+
}, timeoutMs);
|
|
177
|
+
try {
|
|
178
|
+
const res = await fetch(url, {
|
|
179
|
+
headers: { ...headers, Accept: "text/event-stream" },
|
|
180
|
+
signal: abortController.signal
|
|
181
|
+
});
|
|
182
|
+
if (!res.ok) {
|
|
183
|
+
throw traceStreamPreflightError(`Trace stream pre-flight failed: ${res.status} ${res.statusText}`);
|
|
184
|
+
}
|
|
185
|
+
if (!res.body) {
|
|
186
|
+
throw traceStreamPreflightError("Trace stream pre-flight failed: response had no body.");
|
|
187
|
+
}
|
|
188
|
+
reader = res.body.getReader();
|
|
189
|
+
const decoder = new TextDecoder();
|
|
190
|
+
let buffer = "";
|
|
191
|
+
while (true) {
|
|
192
|
+
const { done, value } = await reader.read();
|
|
193
|
+
if (done) {
|
|
194
|
+
throw traceStreamPreflightError("Trace stream pre-flight failed: stream closed before snapshot.");
|
|
195
|
+
}
|
|
196
|
+
buffer += decoder.decode(value, { stream: true });
|
|
197
|
+
const parts = buffer.split("\n\n");
|
|
198
|
+
buffer = parts.pop() ?? "";
|
|
199
|
+
for (const part of parts) {
|
|
200
|
+
if (parseSSEEventName(part) === "snapshot") {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
} catch (err) {
|
|
206
|
+
if (err instanceof EvalRunnerError) {
|
|
207
|
+
throw err;
|
|
208
|
+
}
|
|
209
|
+
if (timedOut) {
|
|
210
|
+
throw traceStreamPreflightError(
|
|
211
|
+
`Trace stream pre-flight timed out after ${timeoutMs}ms before receiving a snapshot.`
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
throw traceStreamPreflightError(
|
|
215
|
+
`Trace stream pre-flight failed: ${err instanceof Error ? err.message : String(err)}`
|
|
216
|
+
);
|
|
217
|
+
} finally {
|
|
218
|
+
clearTimeout(timer);
|
|
219
|
+
reader?.cancel().catch(() => {
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function parseSSEEventName(block) {
|
|
224
|
+
for (const line of block.split("\n")) {
|
|
225
|
+
if (line.startsWith("event:")) {
|
|
226
|
+
return line.slice("event:".length).trim();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
function traceStreamPreflightError(message) {
|
|
232
|
+
return new EvalRunnerError({
|
|
233
|
+
code: "SSE_CONNECT_FAILED",
|
|
234
|
+
message,
|
|
235
|
+
expected: true,
|
|
236
|
+
suggestion: "Make sure the dev server is running (`adk dev`) and trace streaming is available."
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
async function assertChatChannelBound(devServerUrl, headers = {}, logger = defaultLogger) {
|
|
87
240
|
let conversations = [];
|
|
88
241
|
try {
|
|
89
242
|
const res = await fetch(`${devServerUrl}/api/agent`, { headers });
|
|
90
243
|
if (!res.ok) return;
|
|
91
244
|
const agent = await res.json();
|
|
92
245
|
conversations = agent.conversations ?? [];
|
|
93
|
-
} catch {
|
|
246
|
+
} catch (err) {
|
|
247
|
+
logger?.warn(
|
|
248
|
+
`Skipping chat.channel binding pre-flight \u2014 could not query ${devServerUrl}/api/agent: ${err instanceof Error ? err.message : String(err)}`
|
|
249
|
+
);
|
|
94
250
|
return;
|
|
95
251
|
}
|
|
96
252
|
if (conversations.length === 0) return;
|
|
@@ -101,8 +257,9 @@ async function assertChatChannelBound(devServerUrl, headers = {}) {
|
|
|
101
257
|
};
|
|
102
258
|
const bound = conversations.some((c) => matchesChatChannel(c.channel));
|
|
103
259
|
if (bound) return;
|
|
104
|
-
throw new
|
|
105
|
-
|
|
260
|
+
throw new EvalRunnerError({
|
|
261
|
+
code: "CHAT_CHANNEL_UNBOUND",
|
|
262
|
+
message: [
|
|
106
263
|
"No Conversation is bound to `chat.channel` \u2014 messages via `@botpress/chat` will time out silently.",
|
|
107
264
|
"",
|
|
108
265
|
"To fix, bind a Conversation in `src/conversations/` to `chat.channel` (or `*`):",
|
|
@@ -111,8 +268,9 @@ async function assertChatChannelBound(devServerUrl, headers = {}) {
|
|
|
111
268
|
" channel: ['chat.channel', ...], // array form",
|
|
112
269
|
"",
|
|
113
270
|
"The `chat` integration is how evals and `adk chat --single` send messages programmatically."
|
|
114
|
-
].join("\n")
|
|
115
|
-
|
|
271
|
+
].join("\n"),
|
|
272
|
+
expected: true
|
|
273
|
+
});
|
|
116
274
|
}
|
|
117
275
|
|
|
118
276
|
// src/sse-collector.ts
|
|
@@ -139,6 +297,10 @@ var SSECollector = class _SSECollector {
|
|
|
139
297
|
connected = false;
|
|
140
298
|
/** Error from the SSE stream if it died unexpectedly. */
|
|
141
299
|
streamError = null;
|
|
300
|
+
/** Passive counters used only to explain timeout failures. */
|
|
301
|
+
updateEventCount = 0;
|
|
302
|
+
keepaliveEventCount = 0;
|
|
303
|
+
lastEvent = null;
|
|
142
304
|
/** Set while a settle phase is in flight; called by disconnect() to short-circuit. */
|
|
143
305
|
_cancelSettle = null;
|
|
144
306
|
constructor(devServerUrl, headers = {}) {
|
|
@@ -159,10 +321,15 @@ var SSECollector = class _SSECollector {
|
|
|
159
321
|
signal: this.abortController.signal
|
|
160
322
|
});
|
|
161
323
|
if (!response.ok) {
|
|
162
|
-
throw new
|
|
324
|
+
throw new EvalRunnerError({
|
|
325
|
+
code: "SSE_CONNECT_FAILED",
|
|
326
|
+
message: `SSE connection failed: ${response.status} ${response.statusText}`,
|
|
327
|
+
expected: true,
|
|
328
|
+
suggestion: "Make sure the dev server is running (`adk dev`) and reachable."
|
|
329
|
+
});
|
|
163
330
|
}
|
|
164
331
|
if (!response.body) {
|
|
165
|
-
throw new
|
|
332
|
+
throw new EvalRunnerError({ code: "SSE_NO_BODY", message: "SSE response has no body" });
|
|
166
333
|
}
|
|
167
334
|
this.connected = true;
|
|
168
335
|
this._consumeStream(response.body).catch((err) => {
|
|
@@ -207,7 +374,11 @@ var SSECollector = class _SSECollector {
|
|
|
207
374
|
this._removeWaiter(waiter);
|
|
208
375
|
opts.abortSignal?.removeEventListener("abort", onAbort);
|
|
209
376
|
const reason = this.streamError ? `SSE stream died: ${this.streamError.message}` : "no handler.conversation span completed";
|
|
210
|
-
reject(
|
|
377
|
+
reject(
|
|
378
|
+
new Error(
|
|
379
|
+
`waitForTurnComplete timed out after ${opts.timeout}ms \u2014 ${reason}; diagnostics: ${this._formatTurnDiagnostics()}`
|
|
380
|
+
)
|
|
381
|
+
);
|
|
211
382
|
}, opts.timeout);
|
|
212
383
|
const onAbort = () => {
|
|
213
384
|
clearTimeout(timer);
|
|
@@ -416,6 +587,7 @@ ${value}` : value;
|
|
|
416
587
|
* Dispatch a parsed SSE event to the appropriate handler.
|
|
417
588
|
*/
|
|
418
589
|
_handleEvent(eventName, payload) {
|
|
590
|
+
this._recordEvent(eventName);
|
|
419
591
|
if (eventName === "snapshot") {
|
|
420
592
|
const data2 = payload;
|
|
421
593
|
const spans = Array.isArray(data2.spans) ? data2.spans : [];
|
|
@@ -435,6 +607,44 @@ ${value}` : value;
|
|
|
435
607
|
}
|
|
436
608
|
}
|
|
437
609
|
}
|
|
610
|
+
_recordEvent(eventName) {
|
|
611
|
+
this.lastEvent = { name: eventName, at: Date.now() };
|
|
612
|
+
if (eventName === "update") {
|
|
613
|
+
this.updateEventCount++;
|
|
614
|
+
} else if (eventName === "keepalive") {
|
|
615
|
+
this.keepaliveEventCount++;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
_formatTurnDiagnostics() {
|
|
619
|
+
const turnSpans = this.getTurnSpans();
|
|
620
|
+
const spanNames = /* @__PURE__ */ new Set();
|
|
621
|
+
const handlerStatuses = /* @__PURE__ */ new Set();
|
|
622
|
+
for (const span of turnSpans) {
|
|
623
|
+
spanNames.add(span.name);
|
|
624
|
+
if (span.name === "handler.conversation") {
|
|
625
|
+
handlerStatuses.add(span.status);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
const lastEvent = this.lastEvent ? `${this.lastEvent.name} ${Date.now() - this.lastEvent.at}ms ago` : "none";
|
|
629
|
+
return [
|
|
630
|
+
`snapshotReceived=${this.snapshotReceived}`,
|
|
631
|
+
`totalUpdates=${this.updateEventCount}`,
|
|
632
|
+
`totalKeepalives=${this.keepaliveEventCount}`,
|
|
633
|
+
`lastEvent=${lastEvent}`,
|
|
634
|
+
`totalSpans=${this.spanMap.size}`,
|
|
635
|
+
`turnSpans=${turnSpans.length}`,
|
|
636
|
+
`turnSpanNames=${this._formatSet(spanNames)}`,
|
|
637
|
+
`handlerConversationStatuses=${this._formatSet(handlerStatuses)}`
|
|
638
|
+
].join(", ");
|
|
639
|
+
}
|
|
640
|
+
_formatSet(values, limit = 12) {
|
|
641
|
+
if (values.size === 0) {
|
|
642
|
+
return "none";
|
|
643
|
+
}
|
|
644
|
+
const items = Array.from(values);
|
|
645
|
+
const suffix = items.length > limit ? `|+${items.length - limit} more` : "";
|
|
646
|
+
return `${items.slice(0, limit).join("|")}${suffix}`;
|
|
647
|
+
}
|
|
438
648
|
/**
|
|
439
649
|
* Insert or replace a span in the map (dedup by trace+span ID).
|
|
440
650
|
*/
|
|
@@ -628,13 +838,15 @@ var MODEL_ALIASES = ["fast", "best"];
|
|
|
628
838
|
var _cognitive = null;
|
|
629
839
|
var _availableModels = [];
|
|
630
840
|
var _judgeModel = "fast";
|
|
841
|
+
var _log = defaultLogger;
|
|
631
842
|
function _isModelValid(model) {
|
|
632
843
|
if (MODEL_ALIASES.includes(model)) return true;
|
|
633
844
|
if (_availableModels.length === 0) return false;
|
|
634
845
|
return _availableModels.includes(model);
|
|
635
846
|
}
|
|
636
847
|
async function initLLMJudge(credentials, options) {
|
|
637
|
-
const log = options?.logger ??
|
|
848
|
+
const log = options?.logger ?? defaultLogger;
|
|
849
|
+
_log = log;
|
|
638
850
|
const client = new Client({
|
|
639
851
|
token: credentials.token,
|
|
640
852
|
apiUrl: credentials.apiUrl,
|
|
@@ -659,11 +871,12 @@ async function gradeLLMJudge(botResponse, criteria, context) {
|
|
|
659
871
|
const threshold = Math.max(1, Math.min(5, context.passThreshold ?? DEFAULT_PASS_THRESHOLD));
|
|
660
872
|
try {
|
|
661
873
|
if (!_cognitive) {
|
|
874
|
+
_log.warn(`LLM judge unavailable for "${criteria}": no credentials configured (initLLMJudge not called)`);
|
|
662
875
|
return {
|
|
663
876
|
assertion: `llm_judge: "${criteria}"`,
|
|
664
|
-
pass:
|
|
877
|
+
pass: false,
|
|
665
878
|
expected: criteria,
|
|
666
|
-
actual: "
|
|
879
|
+
actual: "FAILED \u2014 LLM judge unavailable: no credentials configured"
|
|
667
880
|
};
|
|
668
881
|
}
|
|
669
882
|
if (!_isModelValid(_judgeModel)) {
|
|
@@ -693,11 +906,12 @@ Criteria: ${criteria}`
|
|
|
693
906
|
const rawContent = output?.choices?.[0]?.content;
|
|
694
907
|
const content = typeof rawContent === "string" ? rawContent : null;
|
|
695
908
|
if (!content) {
|
|
909
|
+
_log.warn(`LLM judge returned an empty response for "${criteria}"`);
|
|
696
910
|
return {
|
|
697
911
|
assertion: `llm_judge: "${criteria}"`,
|
|
698
|
-
pass:
|
|
912
|
+
pass: false,
|
|
699
913
|
expected: criteria,
|
|
700
|
-
actual: "
|
|
914
|
+
actual: "FAILED \u2014 LLM judge returned empty response"
|
|
701
915
|
};
|
|
702
916
|
}
|
|
703
917
|
const verdict = JSON.parse(content);
|
|
@@ -708,11 +922,12 @@ Criteria: ${criteria}`
|
|
|
708
922
|
actual: `Score ${verdict.score}/5 (threshold: ${threshold}) \u2014 ${verdict.reason}`
|
|
709
923
|
};
|
|
710
924
|
} catch (err) {
|
|
925
|
+
_log.warn(`LLM judge failed for "${criteria}": ${err.message}`);
|
|
711
926
|
return {
|
|
712
927
|
assertion: `llm_judge: "${criteria}"`,
|
|
713
|
-
pass:
|
|
928
|
+
pass: false,
|
|
714
929
|
expected: criteria,
|
|
715
|
-
actual: `
|
|
930
|
+
actual: `FAILED \u2014 LLM judge unavailable: ${err.message}`
|
|
716
931
|
};
|
|
717
932
|
}
|
|
718
933
|
}
|
|
@@ -1053,30 +1268,6 @@ function gradeOutcome(mutations, evalDef, workflowSpans) {
|
|
|
1053
1268
|
// src/loader.ts
|
|
1054
1269
|
import { readdirSync, existsSync } from "fs";
|
|
1055
1270
|
import { resolve } from "path";
|
|
1056
|
-
|
|
1057
|
-
// src/definition.ts
|
|
1058
|
-
var Eval = class {
|
|
1059
|
-
name;
|
|
1060
|
-
description;
|
|
1061
|
-
tags;
|
|
1062
|
-
type;
|
|
1063
|
-
setup;
|
|
1064
|
-
conversation;
|
|
1065
|
-
outcome;
|
|
1066
|
-
options;
|
|
1067
|
-
constructor(def) {
|
|
1068
|
-
this.name = def.name;
|
|
1069
|
-
this.conversation = def.conversation;
|
|
1070
|
-
if (def.description !== void 0) this.description = def.description;
|
|
1071
|
-
if (def.tags !== void 0) this.tags = def.tags;
|
|
1072
|
-
if (def.type !== void 0) this.type = def.type;
|
|
1073
|
-
if (def.setup !== void 0) this.setup = def.setup;
|
|
1074
|
-
if (def.outcome !== void 0) this.outcome = def.outcome;
|
|
1075
|
-
if (def.options !== void 0) this.options = def.options;
|
|
1076
|
-
}
|
|
1077
|
-
};
|
|
1078
|
-
|
|
1079
|
-
// src/loader.ts
|
|
1080
1271
|
function isEvalDefinition(value) {
|
|
1081
1272
|
return value !== null && typeof value === "object" && typeof value.name === "string" && value.name !== "" && Array.isArray(value.conversation);
|
|
1082
1273
|
}
|
|
@@ -1087,15 +1278,17 @@ async function loadEvalFile(filePath) {
|
|
|
1087
1278
|
mod = await import(absPath);
|
|
1088
1279
|
} catch (err) {
|
|
1089
1280
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1090
|
-
throw new
|
|
1091
|
-
|
|
1281
|
+
throw new EvalRunnerError({
|
|
1282
|
+
code: "EVAL_LOAD_FAILED",
|
|
1283
|
+
message: `Failed to load eval file ${filePath}: ${msg}
|
|
1092
1284
|
|
|
1093
1285
|
Make sure your eval file:
|
|
1094
1286
|
- Has no syntax or type errors
|
|
1095
1287
|
- Exports one or more \`new Eval({...})\` instances
|
|
1096
1288
|
- Has all dependencies installed (\`bun install\`)`,
|
|
1097
|
-
|
|
1098
|
-
|
|
1289
|
+
expected: true,
|
|
1290
|
+
cause: err
|
|
1291
|
+
});
|
|
1099
1292
|
}
|
|
1100
1293
|
const results = [];
|
|
1101
1294
|
for (const [key, value] of Object.entries(mod)) {
|
|
@@ -1105,9 +1298,11 @@ Make sure your eval file:
|
|
|
1105
1298
|
}
|
|
1106
1299
|
}
|
|
1107
1300
|
if (results.length === 0) {
|
|
1108
|
-
throw new
|
|
1109
|
-
|
|
1110
|
-
|
|
1301
|
+
throw new EvalRunnerError({
|
|
1302
|
+
code: "EVAL_FILE_EMPTY",
|
|
1303
|
+
message: `Invalid eval file ${filePath}: no valid evals found. Export one or more \`new Eval({...})\` instances (as default or named exports).`,
|
|
1304
|
+
expected: true
|
|
1305
|
+
});
|
|
1111
1306
|
}
|
|
1112
1307
|
return results;
|
|
1113
1308
|
}
|
|
@@ -1125,7 +1320,12 @@ async function loadEvalsFromDir(dirPath) {
|
|
|
1125
1320
|
const seen = /* @__PURE__ */ new Set();
|
|
1126
1321
|
for (const e of evals) {
|
|
1127
1322
|
if (seen.has(e.name)) {
|
|
1128
|
-
throw new
|
|
1323
|
+
throw new EvalRunnerError({
|
|
1324
|
+
code: "EVAL_DUPLICATE_NAME",
|
|
1325
|
+
message: `Duplicate eval name "${e.name}" found in ${dirPath} \u2014 names must be unique across the evals directory.`,
|
|
1326
|
+
expected: true,
|
|
1327
|
+
details: { name: e.name }
|
|
1328
|
+
});
|
|
1129
1329
|
}
|
|
1130
1330
|
seen.add(e.name);
|
|
1131
1331
|
}
|
|
@@ -1189,7 +1389,10 @@ async function seedEvalState(client, ctx, setup) {
|
|
|
1189
1389
|
}
|
|
1190
1390
|
if (state.conversation) {
|
|
1191
1391
|
if (!ctx.conversationId) {
|
|
1192
|
-
throw new
|
|
1392
|
+
throw new EvalRunnerError({
|
|
1393
|
+
code: "EVAL_SEED_NO_CONVERSATION",
|
|
1394
|
+
message: "Cannot seed conversation state before a conversation is created."
|
|
1395
|
+
});
|
|
1193
1396
|
}
|
|
1194
1397
|
writes.push(
|
|
1195
1398
|
client.setState({
|
|
@@ -1225,6 +1428,7 @@ function evalMetadata(evalDef) {
|
|
|
1225
1428
|
async function runEval(evalDef, connection, options = {}) {
|
|
1226
1429
|
const devServerUrl = options.devServerUrl || DEFAULT_DEV_SERVER_URL;
|
|
1227
1430
|
const devServerHeaders = options.devServerHeaders ?? {};
|
|
1431
|
+
const logger = options.logger ?? defaultLogger;
|
|
1228
1432
|
const idleTimeout = evalDef.options?.idleTimeout ?? options.idleTimeout ?? DEFAULT_IDLE_TIMEOUT;
|
|
1229
1433
|
const judgePassThreshold = evalDef.options?.judgePassThreshold ?? options.judgePassThreshold;
|
|
1230
1434
|
const start = Date.now();
|
|
@@ -1272,19 +1476,28 @@ async function runEval(evalDef, connection, options = {}) {
|
|
|
1272
1476
|
if (conversationId) {
|
|
1273
1477
|
await collector.connect({ conversationId });
|
|
1274
1478
|
} else if (evalDef.conversation.length > 0) {
|
|
1275
|
-
throw new
|
|
1479
|
+
throw new EvalRunnerError({
|
|
1480
|
+
code: "EVAL_NO_CONVERSATION_ID",
|
|
1481
|
+
message: "Cannot run conversation eval without a conversationId \u2014 no chat session or setup created one."
|
|
1482
|
+
});
|
|
1276
1483
|
}
|
|
1277
1484
|
for (let i = 0; i < evalDef.conversation.length; i++) {
|
|
1278
1485
|
if (options.signal?.aborted) break;
|
|
1279
1486
|
const turn = evalDef.conversation[i];
|
|
1487
|
+
const turnConfigError = (message) => new EvalRunnerError({
|
|
1488
|
+
code: "EVAL_TURN_CONFIG_INVALID",
|
|
1489
|
+
message: `Turn ${i + 1}: ${message}`,
|
|
1490
|
+
expected: true,
|
|
1491
|
+
details: { turn: i + 1 }
|
|
1492
|
+
});
|
|
1280
1493
|
if (turn.expectSilence && turn.assert?.response) {
|
|
1281
|
-
throw
|
|
1494
|
+
throw turnConfigError(`'expectSilence' and 'assert.response' are mutually exclusive.`);
|
|
1282
1495
|
}
|
|
1283
1496
|
if (turn.user && turn.event) {
|
|
1284
|
-
throw
|
|
1497
|
+
throw turnConfigError(`'user' and 'event' are mutually exclusive.`);
|
|
1285
1498
|
}
|
|
1286
1499
|
if (!turn.user && !turn.event) {
|
|
1287
|
-
throw
|
|
1500
|
+
throw turnConfigError(`must have either 'user' or 'event'.`);
|
|
1288
1501
|
}
|
|
1289
1502
|
const turnLabelForEvent = turn.event ? `[event: ${turn.event.type}]` : turn.user;
|
|
1290
1503
|
options.onProgress?.({
|
|
@@ -1328,11 +1541,17 @@ async function runEval(evalDef, connection, options = {}) {
|
|
|
1328
1541
|
const botResponse = turnData.messages.join("\n");
|
|
1329
1542
|
const botDuration = turnData.handlerDuration;
|
|
1330
1543
|
const turnLabel = turn.event ? `[event: ${turn.event.type}]` : turn.user;
|
|
1331
|
-
if (!turn.expectSilence && turnData.messages.length === 0) {
|
|
1332
|
-
throw new Error(`Turn ${i + 1}: bot produced no response.`);
|
|
1333
|
-
}
|
|
1334
1544
|
const evalStart = Date.now();
|
|
1335
1545
|
let assertions = [];
|
|
1546
|
+
const noResponse = !turn.expectSilence && turnData.messages.length === 0;
|
|
1547
|
+
if (noResponse) {
|
|
1548
|
+
assertions.push({
|
|
1549
|
+
assertion: "response",
|
|
1550
|
+
pass: false,
|
|
1551
|
+
expected: "Bot produces a response",
|
|
1552
|
+
actual: `Turn ${i + 1}: bot produced no response.`
|
|
1553
|
+
});
|
|
1554
|
+
}
|
|
1336
1555
|
if (turn.expectSilence) {
|
|
1337
1556
|
const wasSilent = turnData.messages.length === 0;
|
|
1338
1557
|
assertions.push({
|
|
@@ -1342,7 +1561,7 @@ async function runEval(evalDef, connection, options = {}) {
|
|
|
1342
1561
|
actual: wasSilent ? "No response" : `Bot responded: "${botResponse}"`
|
|
1343
1562
|
});
|
|
1344
1563
|
}
|
|
1345
|
-
if (turn.assert?.response) {
|
|
1564
|
+
if (turn.assert?.response && !noResponse) {
|
|
1346
1565
|
assertions = await gradeResponse(botResponse, turn.assert.response, {
|
|
1347
1566
|
userMessage: turnLabel,
|
|
1348
1567
|
...judgePassThreshold !== void 0 ? { judgePassThreshold } : {}
|
|
@@ -1434,13 +1653,19 @@ async function runEval(evalDef, connection, options = {}) {
|
|
|
1434
1653
|
...aborted ? { error: "Eval aborted" } : {}
|
|
1435
1654
|
};
|
|
1436
1655
|
} catch (err) {
|
|
1656
|
+
const adkErr = isAdkError(err) ? err : void 0;
|
|
1657
|
+
if (!adkErr || !adkErr.expected) {
|
|
1658
|
+
logger.error(`Eval "${evalDef.name}" failed unexpectedly: ${err.message}`);
|
|
1659
|
+
options.onException?.(err, { source: "evals", eval_name: evalDef.name });
|
|
1660
|
+
}
|
|
1437
1661
|
return {
|
|
1438
1662
|
...evalMetadata(evalDef),
|
|
1439
1663
|
turns,
|
|
1440
1664
|
outcomeAssertions,
|
|
1441
1665
|
pass: false,
|
|
1442
1666
|
duration: Date.now() - start,
|
|
1443
|
-
error: err.message
|
|
1667
|
+
error: err.message,
|
|
1668
|
+
...adkErr ? { errorCode: adkErr.code } : {}
|
|
1444
1669
|
};
|
|
1445
1670
|
} finally {
|
|
1446
1671
|
collector.disconnect();
|
|
@@ -1488,7 +1713,8 @@ async function runEvalSuite(config, filter) {
|
|
|
1488
1713
|
};
|
|
1489
1714
|
const devServerUrl = config.devServerUrl || DEFAULT_DEV_SERVER_URL;
|
|
1490
1715
|
const devServerHeaders = config.devServerHeaders ?? {};
|
|
1491
|
-
await assertChatChannelBound(devServerUrl, devServerHeaders);
|
|
1716
|
+
await assertChatChannelBound(devServerUrl, devServerHeaders, config.logger);
|
|
1717
|
+
await assertTraceStreamReadable(devServerUrl, devServerHeaders);
|
|
1492
1718
|
const reports = [];
|
|
1493
1719
|
config.onProgress?.({ type: "suite_start", totalEvals: evals.length });
|
|
1494
1720
|
for (let i = 0; i < evals.length; i++) {
|
|
@@ -1508,7 +1734,9 @@ async function runEvalSuite(config, filter) {
|
|
|
1508
1734
|
...config.evalOptions?.judgePassThreshold !== void 0 ? { judgePassThreshold: config.evalOptions.judgePassThreshold } : {},
|
|
1509
1735
|
...config.onProgress !== void 0 ? { onProgress: config.onProgress } : {},
|
|
1510
1736
|
...config.signal !== void 0 ? { signal: config.signal } : {},
|
|
1511
|
-
...config.chatClient !== void 0 ? { chatClient: config.chatClient } : {}
|
|
1737
|
+
...config.chatClient !== void 0 ? { chatClient: config.chatClient } : {},
|
|
1738
|
+
...config.logger !== void 0 ? { logger: config.logger } : {},
|
|
1739
|
+
...config.onException !== void 0 ? { onException: config.onException } : {}
|
|
1512
1740
|
});
|
|
1513
1741
|
reports.push(report);
|
|
1514
1742
|
config.onProgress?.({ type: "eval_complete", evalName: evalDef.name, index: i, report });
|