@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/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 Error("@botpress/chat did not expose a Client export. Pass EvalRunnerConfig.chatClient explicitly.");
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
- get userId() {
108
+ /** Invariant guard — calling any session method before connect() is a runner bug. */
109
+ assertConnected() {
30
110
  if (!this.client) {
31
- throw new Error("ChatSession not connected. Call connect() first.");
111
+ throw new EvalRunnerError({
112
+ code: "CHAT_NOT_CONNECTED",
113
+ message: "ChatSession not connected. Call connect() first."
114
+ });
32
115
  }
33
- return this.client.user.id;
116
+ return this.client;
117
+ }
118
+ get userId() {
119
+ return this.assertConnected().user.id;
34
120
  }
35
121
  async ensureConversation() {
36
- if (!this.client) {
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 this.client.createConversation({});
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
- if (!this.client) {
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 this.client.createMessage({ conversationId, payload: { type: "text", text: message } });
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
- if (!this.client) {
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 this.client.createEvent({ type, payload, conversationId });
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 Error(
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 assertChatChannelBound(devServerUrl, headers = {}) {
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 Error(
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 Error(`SSE connection failed: ${response.status} ${response.statusText}`);
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 Error("SSE response has no body");
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(new Error(`waitForTurnComplete timed out after ${opts.timeout}ms \u2014 ${reason}`));
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 ?? console;
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: true,
877
+ pass: false,
665
878
  expected: criteria,
666
- actual: "SKIPPED \u2014 LLM judge unavailable: no credentials configured"
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: true,
912
+ pass: false,
699
913
  expected: criteria,
700
- actual: "SKIPPED \u2014 LLM judge returned empty response"
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: true,
928
+ pass: false,
714
929
  expected: criteria,
715
- actual: `SKIPPED \u2014 LLM judge unavailable: ${err.message}`
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 Error(
1091
- `Failed to load eval file ${filePath}: ${msg}
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
- { cause: err }
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 Error(
1109
- `Invalid eval file ${filePath}: no valid evals found. Export one or more \`new Eval({...})\` instances (as default or named exports).`
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 Error(`Duplicate eval name "${e.name}" found in ${dirPath}`);
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 Error("Cannot seed conversation state before a conversation is created.");
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 Error("Cannot run conversation eval without a conversationId \u2014 no chat session or setup created one.");
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 new Error(`Turn ${i + 1}: 'expectSilence' and 'assert.response' are mutually exclusive.`);
1494
+ throw turnConfigError(`'expectSilence' and 'assert.response' are mutually exclusive.`);
1282
1495
  }
1283
1496
  if (turn.user && turn.event) {
1284
- throw new Error(`Turn ${i + 1}: 'user' and 'event' are mutually exclusive.`);
1497
+ throw turnConfigError(`'user' and 'event' are mutually exclusive.`);
1285
1498
  }
1286
1499
  if (!turn.user && !turn.event) {
1287
- throw new Error(`Turn ${i + 1}: must have either 'user' or 'event'.`);
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 });