@botpress/evals 1.19.0-beta.7 → 2.0.0-alpha.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.
Files changed (90) hide show
  1. package/dist/client.d.ts +25 -11
  2. package/dist/client.d.ts.map +1 -1
  3. package/dist/client.js +143 -55
  4. package/dist/client.js.map +4 -4
  5. package/dist/definition.d.ts +9 -2
  6. package/dist/definition.d.ts.map +1 -1
  7. package/dist/definition.js.map +2 -2
  8. package/dist/errors.d.ts +15 -0
  9. package/dist/errors.d.ts.map +1 -0
  10. package/dist/graders/index.js +31 -74
  11. package/dist/graders/index.js.map +3 -3
  12. package/dist/graders/llm.d.ts +14 -12
  13. package/dist/graders/llm.d.ts.map +1 -1
  14. package/dist/graders/llm.js +46 -74
  15. package/dist/graders/llm.js.map +3 -3
  16. package/dist/graders/response.js +24 -64
  17. package/dist/graders/response.js.map +3 -3
  18. package/dist/index.d.ts +3 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +10 -0
  21. package/dist/index.js.map +3 -3
  22. package/dist/loader.d.ts.map +1 -1
  23. package/dist/loader.js +22 -8
  24. package/dist/loader.js.map +3 -3
  25. package/dist/manifest.d.ts +13 -0
  26. package/dist/manifest.d.ts.map +1 -0
  27. package/dist/runner.d.ts +7 -1
  28. package/dist/runner.d.ts.map +1 -1
  29. package/dist/runner.js +445 -246
  30. package/dist/runner.js.map +4 -4
  31. package/dist/spans/index.d.ts +5 -0
  32. package/dist/spans/index.d.ts.map +1 -0
  33. package/dist/spans/index.js +687 -0
  34. package/dist/spans/index.js.map +7 -0
  35. package/dist/spans/local-span-source.d.ts +19 -0
  36. package/dist/spans/local-span-source.d.ts.map +1 -0
  37. package/dist/spans/local-span-source.js +505 -0
  38. package/dist/spans/local-span-source.js.map +7 -0
  39. package/dist/spans/span-source.d.ts +40 -0
  40. package/dist/spans/span-source.d.ts.map +1 -0
  41. package/dist/spans/span-source.js +1 -0
  42. package/dist/{sse-collector.d.ts → spans/sse-collector.d.ts} +44 -5
  43. package/dist/spans/sse-collector.d.ts.map +1 -0
  44. package/dist/{sse-collector.js → spans/sse-collector.js} +109 -22
  45. package/dist/spans/sse-collector.js.map +7 -0
  46. package/dist/spans/trace.d.ts.map +1 -0
  47. package/dist/spans/trace.js.map +7 -0
  48. package/dist/spans/vortex-span-source.d.ts +42 -0
  49. package/dist/spans/vortex-span-source.d.ts.map +1 -0
  50. package/dist/spans/vortex-span-source.js +184 -0
  51. package/dist/spans/vortex-span-source.js.map +7 -0
  52. package/dist/stores/eval-definition-loader.d.ts +4 -0
  53. package/dist/stores/eval-definition-loader.d.ts.map +1 -0
  54. package/dist/stores/eval-definition-loader.js +104 -0
  55. package/dist/stores/eval-definition-loader.js.map +7 -0
  56. package/dist/stores/eval-store.d.ts +61 -0
  57. package/dist/stores/eval-store.d.ts.map +1 -0
  58. package/dist/stores/eval-store.js +1 -0
  59. package/dist/stores/eval-store.js.map +7 -0
  60. package/dist/stores/index.d.ts +8 -0
  61. package/dist/stores/index.d.ts.map +1 -0
  62. package/dist/stores/index.js +1118 -0
  63. package/dist/stores/index.js.map +7 -0
  64. package/dist/stores/local-eval-store.d.ts +66 -0
  65. package/dist/stores/local-eval-store.d.ts.map +1 -0
  66. package/dist/stores/local-eval-store.js +557 -0
  67. package/dist/stores/local-eval-store.js.map +7 -0
  68. package/dist/stores/vortex-eval-store-entry.d.ts +6 -0
  69. package/dist/stores/vortex-eval-store-entry.d.ts.map +1 -0
  70. package/dist/stores/vortex-eval-store-entry.js +664 -0
  71. package/dist/stores/vortex-eval-store-entry.js.map +7 -0
  72. package/dist/stores/vortex-eval-store.d.ts +78 -0
  73. package/dist/stores/vortex-eval-store.d.ts.map +1 -0
  74. package/dist/stores/vortex-eval-store.js +562 -0
  75. package/dist/stores/vortex-eval-store.js.map +7 -0
  76. package/dist/transformer.d.ts +2 -2
  77. package/dist/transformer.d.ts.map +1 -1
  78. package/dist/transformer.js +1 -1
  79. package/dist/transformer.js.map +2 -2
  80. package/dist/types.d.ts +55 -33
  81. package/dist/types.d.ts.map +1 -1
  82. package/dist/types.js +5 -1
  83. package/dist/types.js.map +3 -3
  84. package/package.json +66 -6
  85. package/dist/sse-collector.d.ts.map +0 -1
  86. package/dist/sse-collector.js.map +0 -7
  87. package/dist/trace.d.ts.map +0 -1
  88. /package/dist/{trace.js.map → spans/span-source.js.map} +0 -0
  89. /package/dist/{trace.d.ts → spans/trace.d.ts} +0 -0
  90. /package/dist/{trace.js → spans/trace.js} +0 -0
package/dist/runner.js CHANGED
@@ -1,96 +1,204 @@
1
- // src/runner.ts
2
- import { Client as BpClient2 } from "@botpress/client";
1
+ // src/definition.ts
2
+ var Eval = class {
3
+ name;
4
+ description;
5
+ tags;
6
+ type;
7
+ setup;
8
+ conversation;
9
+ outcome;
10
+ options;
11
+ constructor(def) {
12
+ this.name = def.name;
13
+ this.conversation = def.conversation;
14
+ if (def.description !== void 0) this.description = def.description;
15
+ if (def.tags !== void 0) this.tags = def.tags;
16
+ if (def.type !== void 0) this.type = def.type;
17
+ if (def.setup !== void 0) this.setup = def.setup;
18
+ if (def.outcome !== void 0) this.outcome = def.outcome;
19
+ if (def.options !== void 0) this.options = def.options;
20
+ }
21
+ };
22
+
23
+ // src/types.ts
24
+ var defaultLogger = console;
25
+
26
+ // src/errors.ts
27
+ import { AdkError } from "@botpress/analytics";
28
+ var EvalRunnerError = class extends AdkError {
29
+ };
3
30
 
4
31
  // src/client.ts
5
- import { Client as BpClient } from "@botpress/client";
6
- import { createRequire } from "module";
7
- var defaultChatClient = null;
8
- function getDefaultChatClient() {
9
- if (defaultChatClient) return defaultChatClient;
10
- const require2 = createRequire(import.meta.url);
11
- const mod = require2("@botpress/chat");
12
- const ChatClient = mod.default?.Client ?? mod.Client;
13
- if (!ChatClient) {
14
- throw new Error("@botpress/chat did not expose a Client export. Pass EvalRunnerConfig.chatClient explicitly.");
15
- }
16
- return ChatClient;
17
- }
32
+ var TRACE_STREAM_PREFLIGHT_TIMEOUT_MS = 1e4;
18
33
  var ChatSession = class {
19
- constructor(webhookId, chatClient) {
20
- this.webhookId = webhookId;
21
- this.chatClient = chatClient;
34
+ constructor(bpClient, botId, _chatWebhookId, _chatBaseUrl, _chatClient) {
35
+ this.bpClient = bpClient;
36
+ this.botId = botId;
37
+ this._chatWebhookId = _chatWebhookId;
38
+ this._chatBaseUrl = _chatBaseUrl;
39
+ this._chatClient = _chatClient;
22
40
  }
23
41
  client = null;
24
42
  conversationId = null;
25
43
  async connect() {
26
- const ChatClient = this.chatClient ?? getDefaultChatClient();
27
- this.client = await ChatClient.connect({ webhookId: this.webhookId });
44
+ const webhookId = this._chatWebhookId ?? await discoverWebhookId(this.bpClient, this.botId);
45
+ const ChatClientCtor = this._chatClient ?? (await import(
46
+ /* webpackIgnore: true */
47
+ "@botpress/chat"
48
+ )).Client;
49
+ this.client = await ChatClientCtor.connect({
50
+ webhookId,
51
+ ...this._chatBaseUrl ? { baseApiUrl: this._chatBaseUrl } : {}
52
+ });
28
53
  }
29
- get userId() {
54
+ /** Invariant guard — calling any session method before connect() is a runner bug. */
55
+ assertConnected() {
30
56
  if (!this.client) {
31
- throw new Error("ChatSession not connected. Call connect() first.");
57
+ throw new EvalRunnerError({
58
+ code: "CHAT_NOT_CONNECTED",
59
+ message: "ChatSession not connected. Call connect() first."
60
+ });
32
61
  }
33
- return this.client.user.id;
62
+ return this.client;
63
+ }
64
+ get userId() {
65
+ return this.assertConnected().user.id;
34
66
  }
35
67
  async ensureConversation() {
36
- if (!this.client) {
37
- throw new Error("ChatSession not connected. Call connect() first.");
38
- }
68
+ const client = this.assertConnected();
39
69
  if (!this.conversationId) {
40
- const conv = await this.client.createConversation({});
70
+ const conv = await client.createConversation({});
41
71
  this.conversationId = conv.conversation.id;
42
72
  return conv.conversation.id;
43
73
  }
44
74
  return this.conversationId;
45
75
  }
76
+ /**
77
+ * Open a fresh conversation under the same user (the user is fixed at
78
+ * connect()) and make it active. Returns the new conversation id.
79
+ */
80
+ async newConversation() {
81
+ const client = this.assertConnected();
82
+ const conv = await client.createConversation({});
83
+ this.conversationId = conv.conversation.id;
84
+ return conv.conversation.id;
85
+ }
46
86
  /**
47
87
  * Send a user message. Fire-and-forget — observation is via SSECollector.
48
88
  */
49
89
  async sendMessage(message) {
50
- if (!this.client) {
51
- throw new Error("ChatSession not connected. Call connect() first.");
52
- }
90
+ const client = this.assertConnected();
53
91
  const conversationId = await this.ensureConversation();
54
- await this.client.createMessage({ conversationId, payload: { type: "text", text: message } });
92
+ await client.createMessage({ conversationId, payload: { type: "text", text: message } });
55
93
  }
56
- /**
57
- * Send an event. Fire-and-forget — observation is via SSECollector.
58
- */
59
- async sendEvent(type, payload) {
60
- if (!this.client) {
61
- throw new Error("ChatSession not connected. Call connect() first.");
62
- }
94
+ async sendEvent(payload) {
95
+ const client = this.assertConnected();
63
96
  const conversationId = await this.ensureConversation();
64
- await this.client.createEvent({ type, payload, conversationId });
97
+ await client.createEvent({ payload, conversationId });
65
98
  }
66
99
  };
67
- async function discoverWebhookId(botId, token, apiUrl) {
68
- const client = new BpClient({ token, botId, apiUrl });
100
+ async function discoverWebhookId(client, botId) {
69
101
  const { bot } = await client.getBot({ id: botId });
70
102
  const integrations = bot.integrations || {};
71
103
  const chat = Object.values(integrations).find((int) => int.name === "chat");
72
104
  const webhookId = chat?.webhookId;
73
105
  if (!webhookId || typeof webhookId !== "string") {
74
- throw new Error(
75
- [
106
+ throw new EvalRunnerError({
107
+ code: "CHAT_INTEGRATION_MISSING",
108
+ message: [
76
109
  "The `chat` integration is not installed on this bot \u2014 evals require it.",
77
110
  "",
78
111
  "To fix:",
79
- " 1. Install it: adk add chat",
112
+ " 1. Install it: adk integrations add chat",
80
113
  " 2. Redeploy: adk dev (or: adk deploy)"
81
- ].join("\n")
82
- );
114
+ ].join("\n"),
115
+ expected: true
116
+ });
83
117
  }
84
118
  return webhookId;
85
119
  }
86
- async function assertChatChannelBound(devServerUrl, headers = {}) {
120
+ async function assertTraceStreamReadable(devServerUrl, headers = {}, timeoutMs = TRACE_STREAM_PREFLIGHT_TIMEOUT_MS) {
121
+ const url = `${devServerUrl.replace(/\/$/, "")}/api/traces/stream?count=1`;
122
+ const abortController = new AbortController();
123
+ let timedOut = false;
124
+ let reader = null;
125
+ const timer = setTimeout(() => {
126
+ timedOut = true;
127
+ abortController.abort();
128
+ }, timeoutMs);
129
+ try {
130
+ const res = await fetch(url, {
131
+ headers: { ...headers, Accept: "text/event-stream" },
132
+ signal: abortController.signal
133
+ });
134
+ if (!res.ok) {
135
+ throw traceStreamPreflightError(`Trace stream pre-flight failed: ${res.status} ${res.statusText}`);
136
+ }
137
+ if (!res.body) {
138
+ throw traceStreamPreflightError("Trace stream pre-flight failed: response had no body.");
139
+ }
140
+ reader = res.body.getReader();
141
+ const decoder = new TextDecoder();
142
+ let buffer = "";
143
+ while (true) {
144
+ const { done, value } = await reader.read();
145
+ if (done) {
146
+ throw traceStreamPreflightError("Trace stream pre-flight failed: stream closed before snapshot.");
147
+ }
148
+ buffer += decoder.decode(value, { stream: true });
149
+ const parts = buffer.split("\n\n");
150
+ buffer = parts.pop() ?? "";
151
+ for (const part of parts) {
152
+ if (parseSSEEventName(part) === "snapshot") {
153
+ return;
154
+ }
155
+ }
156
+ }
157
+ } catch (err) {
158
+ if (err instanceof EvalRunnerError) {
159
+ throw err;
160
+ }
161
+ if (timedOut) {
162
+ throw traceStreamPreflightError(
163
+ `Trace stream pre-flight timed out after ${timeoutMs}ms before receiving a snapshot.`
164
+ );
165
+ }
166
+ throw traceStreamPreflightError(
167
+ `Trace stream pre-flight failed: ${err instanceof Error ? err.message : String(err)}`
168
+ );
169
+ } finally {
170
+ clearTimeout(timer);
171
+ reader?.cancel().catch(() => {
172
+ });
173
+ }
174
+ }
175
+ function parseSSEEventName(block) {
176
+ for (const line of block.split("\n")) {
177
+ if (line.startsWith("event:")) {
178
+ return line.slice("event:".length).trim();
179
+ }
180
+ }
181
+ return null;
182
+ }
183
+ function traceStreamPreflightError(message) {
184
+ return new EvalRunnerError({
185
+ code: "SSE_CONNECT_FAILED",
186
+ message,
187
+ expected: true,
188
+ suggestion: "Make sure the dev server is running (`adk dev`) and trace streaming is available."
189
+ });
190
+ }
191
+ async function assertChatChannelBound(devServerUrl, headers = {}, logger = defaultLogger) {
87
192
  let conversations = [];
88
193
  try {
89
194
  const res = await fetch(`${devServerUrl}/api/agent`, { headers });
90
195
  if (!res.ok) return;
91
196
  const agent = await res.json();
92
197
  conversations = agent.conversations ?? [];
93
- } catch {
198
+ } catch (err) {
199
+ logger?.warn(
200
+ `Skipping chat.channel binding pre-flight \u2014 could not query ${devServerUrl}/api/agent: ${err instanceof Error ? err.message : String(err)}`
201
+ );
94
202
  return;
95
203
  }
96
204
  if (conversations.length === 0) return;
@@ -101,8 +209,9 @@ async function assertChatChannelBound(devServerUrl, headers = {}) {
101
209
  };
102
210
  const bound = conversations.some((c) => matchesChatChannel(c.channel));
103
211
  if (bound) return;
104
- throw new Error(
105
- [
212
+ throw new EvalRunnerError({
213
+ code: "CHAT_CHANNEL_UNBOUND",
214
+ message: [
106
215
  "No Conversation is bound to `chat.channel` \u2014 messages via `@botpress/chat` will time out silently.",
107
216
  "",
108
217
  "To fix, bind a Conversation in `src/conversations/` to `chat.channel` (or `*`):",
@@ -111,17 +220,22 @@ async function assertChatChannelBound(devServerUrl, headers = {}) {
111
220
  " channel: ['chat.channel', ...], // array form",
112
221
  "",
113
222
  "The `chat` integration is how evals and `adk chat --single` send messages programmatically."
114
- ].join("\n")
115
- );
223
+ ].join("\n"),
224
+ expected: true
225
+ });
116
226
  }
117
227
 
118
- // src/sse-collector.ts
228
+ // src/spans/sse-collector.ts
119
229
  function spanKey(span) {
120
230
  return `${span.id.trace}:${span.id.span}`;
121
231
  }
122
232
  function spanData(span) {
123
233
  return span.data && typeof span.data === "object" ? span.data : {};
124
234
  }
235
+ var TURN_HANDLER_SPAN_NAMES = /* @__PURE__ */ new Set(["handler.conversation", "handler.event"]);
236
+ function isTurnHandlerSpan(span) {
237
+ return TURN_HANDLER_SPAN_NAMES.has(span.name);
238
+ }
125
239
  var SSECollector = class _SSECollector {
126
240
  devServerUrl;
127
241
  headers;
@@ -139,6 +253,10 @@ var SSECollector = class _SSECollector {
139
253
  connected = false;
140
254
  /** Error from the SSE stream if it died unexpectedly. */
141
255
  streamError = null;
256
+ /** Passive counters used only to explain timeout failures. */
257
+ updateEventCount = 0;
258
+ keepaliveEventCount = 0;
259
+ lastEvent = null;
142
260
  /** Set while a settle phase is in flight; called by disconnect() to short-circuit. */
143
261
  _cancelSettle = null;
144
262
  constructor(devServerUrl, headers = {}) {
@@ -154,40 +272,66 @@ var SSECollector = class _SSECollector {
154
272
  const attributeValue = "conversationId" in filter ? filter.conversationId : filter.workflowId;
155
273
  const url = `${this.devServerUrl}/api/traces/stream?attributeName=${encodeURIComponent(attributeName)}&attributeValue=${encodeURIComponent(attributeValue)}`;
156
274
  this.abortController = new AbortController();
275
+ const myController = this.abortController;
157
276
  const response = await fetch(url, {
158
277
  headers: { ...this.headers, Accept: "text/event-stream" },
159
- signal: this.abortController.signal
278
+ signal: myController.signal
160
279
  });
161
280
  if (!response.ok) {
162
- throw new Error(`SSE connection failed: ${response.status} ${response.statusText}`);
281
+ throw new EvalRunnerError({
282
+ code: "SSE_CONNECT_FAILED",
283
+ message: `SSE connection failed: ${response.status} ${response.statusText}`,
284
+ expected: true,
285
+ suggestion: "Make sure the dev server is running (`adk dev`) and reachable."
286
+ });
163
287
  }
164
288
  if (!response.body) {
165
- throw new Error("SSE response has no body");
289
+ throw new EvalRunnerError({ code: "SSE_NO_BODY", message: "SSE response has no body" });
166
290
  }
167
291
  this.connected = true;
168
292
  this._consumeStream(response.body).catch((err) => {
169
- if (this.connected) {
293
+ if (this.connected && this.abortController === myController) {
170
294
  this.streamError = err instanceof Error ? err : new Error(String(err));
171
295
  }
172
296
  });
173
297
  await this._waitForSnapshot();
174
298
  }
299
+ /**
300
+ * Re-point at a new conversation, keeping `spanMap` and `seenHandlerKeys` (so
301
+ * the earlier conversation stays visible and its completed handler can't
302
+ * satisfy the next turn). Resets the snapshot latch so connect() awaits the
303
+ * new conversation's snapshot.
304
+ */
305
+ async repoint(filter) {
306
+ this.connected = false;
307
+ this.abortController?.abort();
308
+ this.abortController = null;
309
+ this._cancelSettle?.();
310
+ this._cancelSettle = null;
311
+ this.waiters = [];
312
+ this.snapshotReceived = false;
313
+ this.snapshotResolvers = [];
314
+ this.streamError = null;
315
+ await this.connect(filter);
316
+ }
175
317
  /**
176
318
  * Mark the beginning of a new turn.
177
- * Records which handler.conversation span IDs have been seen so far,
178
- * so waitForTurnComplete() only resolves on NEW handler completions.
319
+ * Records which turn-handler span IDs (handler.conversation / handler.event)
320
+ * have been seen so far, so waitForTurnComplete() only resolves on NEW
321
+ * handler completions.
179
322
  */
180
323
  startTurn() {
181
324
  this.turnStartKeys = new Set(this.spanMap.keys());
182
325
  for (const [key, span] of this.spanMap) {
183
- if (span.name === "handler.conversation") {
326
+ if (isTurnHandlerSpan(span)) {
184
327
  this.seenHandlerKeys.add(key);
185
328
  }
186
329
  }
187
330
  }
188
331
  static SETTLE_MS = 100;
189
332
  /**
190
- * Wait for a handler.conversation span to reach terminal status (ok or error).
333
+ * Wait for a turn-handler span (handler.conversation or handler.event) to
334
+ * reach terminal status (ok or error).
191
335
  * Only considers spans NOT seen before the last startTurn() call.
192
336
  *
193
337
  * After the handler closes, waits for a quiet period (no new spans for SETTLE_MS)
@@ -196,8 +340,10 @@ var SSECollector = class _SSECollector {
196
340
  * spans may arrive slightly after the handler close event.
197
341
  */
198
342
  async waitForTurnComplete(opts) {
199
- if (this._hasNewCompletedHandler()) {
200
- return this._settleAfterHandler();
343
+ const accept = opts.acceptSpanNames;
344
+ const settle = { quietMs: opts.settleQuietMs, maxMs: opts.settleMaxMs };
345
+ if (this._hasNewCompletedHandler(accept)) {
346
+ return this._settleAfterHandler(settle);
201
347
  }
202
348
  if (opts.abortSignal?.aborted) {
203
349
  throw new Error("waitForTurnComplete aborted");
@@ -206,8 +352,13 @@ var SSECollector = class _SSECollector {
206
352
  const timer = setTimeout(() => {
207
353
  this._removeWaiter(waiter);
208
354
  opts.abortSignal?.removeEventListener("abort", onAbort);
209
- 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}`));
355
+ const expected = accept ? [...accept].join(" or ") : "handler.conversation or handler.event";
356
+ const reason = this.streamError ? `SSE stream died: ${this.streamError.message}` : `no ${expected} span completed`;
357
+ reject(
358
+ new Error(
359
+ `waitForTurnComplete timed out after ${opts.timeout}ms \u2014 ${reason}; diagnostics: ${this._formatTurnDiagnostics()}`
360
+ )
361
+ );
211
362
  }, opts.timeout);
212
363
  const onAbort = () => {
213
364
  clearTimeout(timer);
@@ -216,11 +367,11 @@ var SSECollector = class _SSECollector {
216
367
  };
217
368
  opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
218
369
  const waiter = (_span) => {
219
- if (this._hasNewCompletedHandler()) {
370
+ if (this._hasNewCompletedHandler(accept)) {
220
371
  clearTimeout(timer);
221
372
  this._removeWaiter(waiter);
222
373
  opts.abortSignal?.removeEventListener("abort", onAbort);
223
- this._settleAfterHandler().then(resolve2, reject);
374
+ this._settleAfterHandler(settle).then(resolve2, reject);
224
375
  }
225
376
  };
226
377
  this.waiters.push(waiter);
@@ -234,7 +385,9 @@ var SSECollector = class _SSECollector {
234
385
  * Exposes a cancellation hook via _cancelSettle so disconnect() can short-
235
386
  * circuit an in-flight settle phase and avoid leaking timers for up to 2s.
236
387
  */
237
- _settleAfterHandler() {
388
+ _settleAfterHandler(settle) {
389
+ const quietMs = settle?.quietMs ?? _SSECollector.SETTLE_MS;
390
+ const maxMs = settle?.maxMs ?? _SSECollector.MAX_SETTLE_MS;
238
391
  return new Promise((resolve2) => {
239
392
  let resolved = false;
240
393
  const done = () => {
@@ -246,12 +399,12 @@ var SSECollector = class _SSECollector {
246
399
  this._cancelSettle = null;
247
400
  resolve2();
248
401
  };
249
- const hardCap = setTimeout(done, _SSECollector.MAX_SETTLE_MS);
250
- let settleTimer = setTimeout(done, _SSECollector.SETTLE_MS);
402
+ const hardCap = setTimeout(done, maxMs);
403
+ let settleTimer = setTimeout(done, quietMs);
251
404
  const settleWaiter = () => {
252
405
  if (resolved) return;
253
406
  clearTimeout(settleTimer);
254
- settleTimer = setTimeout(done, _SSECollector.SETTLE_MS);
407
+ settleTimer = setTimeout(done, quietMs);
255
408
  };
256
409
  this.waiters.push(settleWaiter);
257
410
  this._cancelSettle = done;
@@ -416,6 +569,7 @@ ${value}` : value;
416
569
  * Dispatch a parsed SSE event to the appropriate handler.
417
570
  */
418
571
  _handleEvent(eventName, payload) {
572
+ this._recordEvent(eventName);
419
573
  if (eventName === "snapshot") {
420
574
  const data2 = payload;
421
575
  const spans = Array.isArray(data2.spans) ? data2.spans : [];
@@ -435,6 +589,44 @@ ${value}` : value;
435
589
  }
436
590
  }
437
591
  }
592
+ _recordEvent(eventName) {
593
+ this.lastEvent = { name: eventName, at: Date.now() };
594
+ if (eventName === "update") {
595
+ this.updateEventCount++;
596
+ } else if (eventName === "keepalive") {
597
+ this.keepaliveEventCount++;
598
+ }
599
+ }
600
+ _formatTurnDiagnostics() {
601
+ const turnSpans = this.getTurnSpans();
602
+ const spanNames = /* @__PURE__ */ new Set();
603
+ const handlerStatuses = /* @__PURE__ */ new Set();
604
+ for (const span of turnSpans) {
605
+ spanNames.add(span.name);
606
+ if (isTurnHandlerSpan(span)) {
607
+ handlerStatuses.add(span.status);
608
+ }
609
+ }
610
+ const lastEvent = this.lastEvent ? `${this.lastEvent.name} ${Date.now() - this.lastEvent.at}ms ago` : "none";
611
+ return [
612
+ `snapshotReceived=${this.snapshotReceived}`,
613
+ `totalUpdates=${this.updateEventCount}`,
614
+ `totalKeepalives=${this.keepaliveEventCount}`,
615
+ `lastEvent=${lastEvent}`,
616
+ `totalSpans=${this.spanMap.size}`,
617
+ `turnSpans=${turnSpans.length}`,
618
+ `turnSpanNames=${this._formatSet(spanNames)}`,
619
+ `turnHandlerStatuses=${this._formatSet(handlerStatuses)}`
620
+ ].join(", ");
621
+ }
622
+ _formatSet(values, limit = 12) {
623
+ if (values.size === 0) {
624
+ return "none";
625
+ }
626
+ const items = Array.from(values);
627
+ const suffix = items.length > limit ? `|+${items.length - limit} more` : "";
628
+ return `${items.slice(0, limit).join("|")}${suffix}`;
629
+ }
438
630
  /**
439
631
  * Insert or replace a span in the map (dedup by trace+span ID).
440
632
  */
@@ -460,7 +652,7 @@ ${value}` : value;
460
652
  }
461
653
  }
462
654
  /**
463
- * Check if there is a handler.conversation span that:
655
+ * Check if there is a turn-handler span (handler.conversation / handler.event) that:
464
656
  * 1. Was NOT seen before startTurn()
465
657
  * 2. Has terminal status (ok or error)
466
658
  *
@@ -469,9 +661,9 @@ ${value}` : value;
469
661
  * waitForTurnComplete check (which would otherwise resolve instantly
470
662
  * against stale data instead of waiting for the new turn's handler).
471
663
  */
472
- _hasNewCompletedHandler() {
664
+ _hasNewCompletedHandler(acceptSpanNames) {
473
665
  for (const [key, span] of this.spanMap) {
474
- if (span.name === "handler.conversation" && !this.seenHandlerKeys.has(key) && (span.status === "ok" || span.status === "error")) {
666
+ if (isTurnHandlerSpan(span) && (!acceptSpanNames || acceptSpanNames.has(span.name)) && !this.seenHandlerKeys.has(key) && (span.status === "ok" || span.status === "error")) {
475
667
  this.seenHandlerKeys.add(key);
476
668
  return true;
477
669
  }
@@ -498,6 +690,38 @@ ${value}` : value;
498
690
  }
499
691
  };
500
692
 
693
+ // src/spans/local-span-source.ts
694
+ var LocalSpanSource = class {
695
+ collector;
696
+ constructor(devServerUrl, headers = {}) {
697
+ this.collector = new SSECollector(devServerUrl, headers);
698
+ }
699
+ async connect(filter) {
700
+ await this.collector.connect(filter);
701
+ }
702
+ async repoint(filter) {
703
+ await this.collector.repoint(filter);
704
+ }
705
+ startTurn() {
706
+ this.collector.startTurn();
707
+ }
708
+ async waitForTurnComplete(opts) {
709
+ await this.collector.waitForTurnComplete(opts);
710
+ }
711
+ async waitForWorkflow(name, opts) {
712
+ await this.collector.waitForWorkflow(name, opts);
713
+ }
714
+ getTurnSpans() {
715
+ return this.collector.getTurnSpans();
716
+ }
717
+ getAllSpans() {
718
+ return this.collector.getAllSpans();
719
+ }
720
+ disconnect() {
721
+ this.collector.disconnect();
722
+ }
723
+ };
724
+
501
725
  // src/transformer.ts
502
726
  function data(span) {
503
727
  return span.data && typeof span.data === "object" ? span.data : {};
@@ -585,7 +809,7 @@ function extractWorkflowSpans(spans) {
585
809
  }
586
810
  function extractHandler(spans) {
587
811
  const handler = spans.find(
588
- (s) => (s.name === "handler.conversation" || s.name === "handler.workflow") && s.status !== "running"
812
+ (s) => (s.name === "handler.conversation" || s.name === "handler.event" || s.name === "handler.workflow") && s.status !== "running"
589
813
  );
590
814
  if (!handler) {
591
815
  return { handlerDuration: 0, handlerStatus: "ok" };
@@ -597,53 +821,26 @@ function extractHandler(spans) {
597
821
  }
598
822
 
599
823
  // src/graders/llm.ts
600
- import { Client } from "@botpress/client";
601
824
  import { Cognitive } from "@botpress/cognitive";
602
- var DEFAULT_PASS_THRESHOLD = 3;
603
- function buildJudgeSystemPrompt(passThreshold) {
604
- return `You are an evaluation judge for a chatbot. You will be given:
605
- - The user's message
606
- - The bot's response
607
- - Grading criteria
608
-
609
- Your job is to determine if the bot's response meets the criteria.
610
-
611
- Respond with a JSON object:
612
- {
613
- "pass": true/false,
614
- "score": 1-5,
615
- "reason": "brief explanation"
616
- }
617
-
618
- Scoring guide:
619
- - 5: Fully meets criteria, excellent response
620
- - 4: Mostly meets criteria, minor issues
621
- - 3: Partially meets criteria, notable gaps
622
- - 2: Barely meets criteria, significant issues
623
- - 1: Does not meet criteria
624
-
625
- A score of ${passThreshold} or above is a pass.`;
626
- }
825
+ import { Zai } from "@botpress/zai";
627
826
  var MODEL_ALIASES = ["fast", "best"];
628
- var _cognitive = null;
827
+ var _zai = null;
629
828
  var _availableModels = [];
630
829
  var _judgeModel = "fast";
830
+ var _failClosed = true;
831
+ var _log = defaultLogger;
631
832
  function _isModelValid(model) {
632
833
  if (MODEL_ALIASES.includes(model)) return true;
633
834
  if (_availableModels.length === 0) return false;
634
835
  return _availableModels.includes(model);
635
836
  }
636
- async function initLLMJudge(credentials, options) {
637
- const log = options?.logger ?? console;
638
- const client = new Client({
639
- token: credentials.token,
640
- apiUrl: credentials.apiUrl,
641
- botId: credentials.botId,
642
- ...credentials.workspaceId !== void 0 ? { workspaceId: credentials.workspaceId } : {}
643
- });
644
- _cognitive = new Cognitive({ client, __experimental_beta: true });
837
+ async function initLLMJudge(client, options) {
838
+ const log = options?.logger ?? defaultLogger;
839
+ _log = log;
840
+ _failClosed = options?.failClosed ?? true;
841
+ const cognitive = new Cognitive({ client, __experimental_beta: true });
645
842
  try {
646
- _availableModels = Array.from((await _cognitive.fetchRemoteModels()).keys());
843
+ _availableModels = Array.from((await cognitive.fetchRemoteModels()).keys());
647
844
  } catch (err) {
648
845
  log.warn(`Failed to fetch available models: ${err.message}. LLM judge will be unavailable.`);
649
846
  _availableModels = [];
@@ -654,65 +851,44 @@ async function initLLMJudge(credentials, options) {
654
851
  } else if (!_isModelValid(_judgeModel)) {
655
852
  log.warn(`Configured LLM judge model "${_judgeModel}" is invalid. Run "adk models" to list available models.`);
656
853
  }
854
+ _zai = new Zai({ client: cognitive, modelId: _judgeModel });
657
855
  }
658
856
  async function gradeLLMJudge(botResponse, criteria, context) {
659
- const threshold = Math.max(1, Math.min(5, context.passThreshold ?? DEFAULT_PASS_THRESHOLD));
857
+ const assertion = `llm_judge: "${criteria}"`;
660
858
  try {
661
- if (!_cognitive) {
859
+ if (!_zai) {
860
+ _log.warn(`LLM judge unavailable for "${criteria}": no credentials configured (initLLMJudge not called)`);
662
861
  return {
663
- assertion: `llm_judge: "${criteria}"`,
664
- pass: true,
862
+ assertion,
863
+ pass: !_failClosed,
864
+ skipped: true,
665
865
  expected: criteria,
666
- actual: "SKIPPED \u2014 LLM judge unavailable: no credentials configured"
866
+ actual: "FAILED \u2014 LLM judge unavailable: no credentials configured"
667
867
  };
668
868
  }
669
869
  if (!_isModelValid(_judgeModel)) {
670
870
  return {
671
- assertion: `llm_judge: "${criteria}"`,
871
+ assertion,
672
872
  pass: false,
673
873
  expected: criteria,
674
874
  actual: "FAILED \u2014 invalid LLM judge model"
675
875
  };
676
876
  }
677
- const { output } = await _cognitive.generateContent({
678
- model: _judgeModel,
679
- temperature: 0,
680
- responseFormat: "json_object",
681
- systemPrompt: buildJudgeSystemPrompt(threshold),
682
- messages: [
683
- {
684
- role: "user",
685
- content: `User message: ${context.userMessage}
686
-
687
- Bot response: ${botResponse}
688
-
689
- Criteria: ${criteria}`
690
- }
691
- ]
692
- });
693
- const rawContent = output?.choices?.[0]?.content;
694
- const content = typeof rawContent === "string" ? rawContent : null;
695
- if (!content) {
696
- return {
697
- assertion: `llm_judge: "${criteria}"`,
698
- pass: true,
699
- expected: criteria,
700
- actual: "SKIPPED \u2014 LLM judge returned empty response"
701
- };
702
- }
703
- const verdict = JSON.parse(content);
877
+ const { output } = await _zai.check({ userMessage: context.userMessage, botResponse }, criteria).result();
704
878
  return {
705
- assertion: `llm_judge: "${criteria}"`,
706
- pass: verdict.score >= threshold,
879
+ assertion,
880
+ pass: output.value,
707
881
  expected: criteria,
708
- actual: `Score ${verdict.score}/5 (threshold: ${threshold}) \u2014 ${verdict.reason}`
882
+ actual: `${output.value ? "Pass" : "Fail"} \u2014 ${output.explanation}`
709
883
  };
710
884
  } catch (err) {
885
+ _log.warn(`LLM judge failed for "${criteria}": ${err.message}`);
711
886
  return {
712
- assertion: `llm_judge: "${criteria}"`,
713
- pass: true,
887
+ assertion,
888
+ pass: !_failClosed,
889
+ skipped: true,
714
890
  expected: criteria,
715
- actual: `SKIPPED \u2014 LLM judge unavailable: ${err.message}`
891
+ actual: `FAILED \u2014 LLM judge unavailable: ${err.message}`
716
892
  };
717
893
  }
718
894
  }
@@ -1053,30 +1229,6 @@ function gradeOutcome(mutations, evalDef, workflowSpans) {
1053
1229
  // src/loader.ts
1054
1230
  import { readdirSync, existsSync } from "fs";
1055
1231
  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
1232
  function isEvalDefinition(value) {
1081
1233
  return value !== null && typeof value === "object" && typeof value.name === "string" && value.name !== "" && Array.isArray(value.conversation);
1082
1234
  }
@@ -1087,15 +1239,17 @@ async function loadEvalFile(filePath) {
1087
1239
  mod = await import(absPath);
1088
1240
  } catch (err) {
1089
1241
  const msg = err instanceof Error ? err.message : String(err);
1090
- throw new Error(
1091
- `Failed to load eval file ${filePath}: ${msg}
1242
+ throw new EvalRunnerError({
1243
+ code: "EVAL_LOAD_FAILED",
1244
+ message: `Failed to load eval file ${filePath}: ${msg}
1092
1245
 
1093
1246
  Make sure your eval file:
1094
1247
  - Has no syntax or type errors
1095
1248
  - Exports one or more \`new Eval({...})\` instances
1096
1249
  - Has all dependencies installed (\`bun install\`)`,
1097
- { cause: err }
1098
- );
1250
+ expected: true,
1251
+ cause: err
1252
+ });
1099
1253
  }
1100
1254
  const results = [];
1101
1255
  for (const [key, value] of Object.entries(mod)) {
@@ -1105,9 +1259,11 @@ Make sure your eval file:
1105
1259
  }
1106
1260
  }
1107
1261
  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
- );
1262
+ throw new EvalRunnerError({
1263
+ code: "EVAL_FILE_EMPTY",
1264
+ message: `Invalid eval file ${filePath}: no valid evals found. Export one or more \`new Eval({...})\` instances (as default or named exports).`,
1265
+ expected: true
1266
+ });
1111
1267
  }
1112
1268
  return results;
1113
1269
  }
@@ -1125,7 +1281,12 @@ async function loadEvalsFromDir(dirPath) {
1125
1281
  const seen = /* @__PURE__ */ new Set();
1126
1282
  for (const e of evals) {
1127
1283
  if (seen.has(e.name)) {
1128
- throw new Error(`Duplicate eval name "${e.name}" found in ${dirPath}`);
1284
+ throw new EvalRunnerError({
1285
+ code: "EVAL_DUPLICATE_NAME",
1286
+ message: `Duplicate eval name "${e.name}" found in ${dirPath} \u2014 names must be unique across the evals directory.`,
1287
+ expected: true,
1288
+ details: { name: e.name }
1289
+ });
1129
1290
  }
1130
1291
  seen.add(e.name);
1131
1292
  }
@@ -1148,6 +1309,7 @@ function filterEvals(evals, filter) {
1148
1309
  }
1149
1310
 
1150
1311
  // src/runner.ts
1312
+ import { isAdkError } from "@botpress/analytics";
1151
1313
  import { randomUUID } from "crypto";
1152
1314
  var DEFAULT_IDLE_TIMEOUT = 3e4;
1153
1315
  var DEFAULT_DEV_SERVER_URL = "http://localhost:3001";
@@ -1189,7 +1351,10 @@ async function seedEvalState(client, ctx, setup) {
1189
1351
  }
1190
1352
  if (state.conversation) {
1191
1353
  if (!ctx.conversationId) {
1192
- throw new Error("Cannot seed conversation state before a conversation is created.");
1354
+ throw new EvalRunnerError({
1355
+ code: "EVAL_SEED_NO_CONVERSATION",
1356
+ message: "Cannot seed conversation state before a conversation is created."
1357
+ });
1193
1358
  }
1194
1359
  writes.push(
1195
1360
  client.setState({
@@ -1225,32 +1390,28 @@ function evalMetadata(evalDef) {
1225
1390
  async function runEval(evalDef, connection, options = {}) {
1226
1391
  const devServerUrl = options.devServerUrl || DEFAULT_DEV_SERVER_URL;
1227
1392
  const devServerHeaders = options.devServerHeaders ?? {};
1393
+ const logger = options.logger ?? defaultLogger;
1228
1394
  const idleTimeout = evalDef.options?.idleTimeout ?? options.idleTimeout ?? DEFAULT_IDLE_TIMEOUT;
1229
1395
  const judgePassThreshold = evalDef.options?.judgePassThreshold ?? options.judgePassThreshold;
1230
1396
  const start = Date.now();
1231
1397
  const turns = [];
1232
1398
  let outcomeAssertions = [];
1233
- const collector = new SSECollector(devServerUrl, devServerHeaders);
1399
+ const collector = options.spanSource ?? new LocalSpanSource(devServerUrl, devServerHeaders);
1234
1400
  try {
1235
- const session = new ChatSession(connection.webhookId, options.chatClient);
1401
+ const session = new ChatSession(
1402
+ connection.client,
1403
+ connection.botId,
1404
+ options.chatWebhookId,
1405
+ options.chatBaseUrl,
1406
+ options.chatClient
1407
+ );
1236
1408
  await session.connect();
1237
- let bpClient = null;
1238
- const getBpClient = () => {
1239
- if (!bpClient) {
1240
- bpClient = new BpClient2({
1241
- token: connection.token,
1242
- botId: connection.botId,
1243
- apiUrl: connection.apiUrl
1244
- });
1245
- }
1246
- return bpClient;
1247
- };
1248
1409
  let conversationId = "";
1249
1410
  if (evalDef.setup?.state?.conversation || evalDef.setup?.workflow) {
1250
1411
  conversationId = await session.ensureConversation();
1251
1412
  }
1252
1413
  await seedEvalState(
1253
- getBpClient(),
1414
+ connection.client,
1254
1415
  {
1255
1416
  botId: connection.botId,
1256
1417
  userId: session.userId,
@@ -1259,7 +1420,7 @@ async function runEval(evalDef, connection, options = {}) {
1259
1420
  evalDef.setup
1260
1421
  );
1261
1422
  await triggerEvalWorkflow(
1262
- getBpClient(),
1423
+ connection.client,
1263
1424
  {
1264
1425
  userId: session.userId,
1265
1426
  ...conversationId ? { conversationId } : {}
@@ -1272,38 +1433,57 @@ async function runEval(evalDef, connection, options = {}) {
1272
1433
  if (conversationId) {
1273
1434
  await collector.connect({ conversationId });
1274
1435
  } 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.");
1436
+ throw new EvalRunnerError({
1437
+ code: "EVAL_NO_CONVERSATION_ID",
1438
+ message: "Cannot run conversation eval without a conversationId \u2014 no chat session or setup created one."
1439
+ });
1276
1440
  }
1277
1441
  for (let i = 0; i < evalDef.conversation.length; i++) {
1278
1442
  if (options.signal?.aborted) break;
1279
1443
  const turn = evalDef.conversation[i];
1444
+ const turnConfigError = (message) => new EvalRunnerError({
1445
+ code: "EVAL_TURN_CONFIG_INVALID",
1446
+ message: `Turn ${i + 1}: ${message}`,
1447
+ expected: true,
1448
+ details: { turn: i + 1 }
1449
+ });
1280
1450
  if (turn.expectSilence && turn.assert?.response) {
1281
- throw new Error(`Turn ${i + 1}: 'expectSilence' and 'assert.response' are mutually exclusive.`);
1451
+ throw turnConfigError(`'expectSilence' and 'assert.response' are mutually exclusive.`);
1282
1452
  }
1283
1453
  if (turn.user && turn.event) {
1284
- throw new Error(`Turn ${i + 1}: 'user' and 'event' are mutually exclusive.`);
1454
+ throw turnConfigError(`'user' and 'event' are mutually exclusive.`);
1285
1455
  }
1286
1456
  if (!turn.user && !turn.event) {
1287
- throw new Error(`Turn ${i + 1}: must have either 'user' or 'event'.`);
1457
+ throw turnConfigError(`must have either 'user' or 'event'.`);
1288
1458
  }
1289
- const turnLabelForEvent = turn.event ? `[event: ${turn.event.type}]` : turn.user;
1290
- options.onProgress?.({
1459
+ const turnLabel = turn.event ? "[event]" : turn.user;
1460
+ await options.onProgress?.({
1291
1461
  type: "turn_start",
1292
1462
  evalName: evalDef.name,
1293
1463
  evalIndex: options.evalIndex ?? 0,
1294
- turnNumber: i + 1,
1464
+ turnIndex: i,
1295
1465
  totalTurns: evalDef.conversation.length,
1296
- userMessage: turnLabelForEvent
1466
+ userMessage: turnLabel
1297
1467
  });
1468
+ if (turn.newConversation && i > 0) {
1469
+ const newConversationId = await session.newConversation();
1470
+ await collector.repoint({ conversationId: newConversationId });
1471
+ }
1298
1472
  collector.startTurn();
1299
1473
  if (turn.event) {
1300
- await session.sendEvent(turn.event.type, turn.event.payload ?? {});
1474
+ await session.sendEvent(turn.event.payload);
1301
1475
  } else {
1302
1476
  await session.sendMessage(turn.user);
1303
1477
  }
1304
1478
  try {
1305
1479
  await collector.waitForTurnComplete({
1306
1480
  timeout: idleTimeout,
1481
+ acceptSpanNames: turn.event ? /* @__PURE__ */ new Set(["handler.event"]) : /* @__PURE__ */ new Set(["handler.conversation"]),
1482
+ // Long quiet window: a tool-started workflow and its callback only run
1483
+ // AFTER the handler closes; sending the next message into that window
1484
+ // can get it absorbed by their transcript saves and silently skipped.
1485
+ settleQuietMs: 1500,
1486
+ settleMaxMs: 15e3,
1307
1487
  ...options.signal !== void 0 ? { abortSignal: options.signal } : {}
1308
1488
  });
1309
1489
  } catch (err) {
@@ -1327,12 +1507,17 @@ async function runEval(evalDef, connection, options = {}) {
1327
1507
  const turnData = transformSpans(turnSpans);
1328
1508
  const botResponse = turnData.messages.join("\n");
1329
1509
  const botDuration = turnData.handlerDuration;
1330
- 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
1510
  const evalStart = Date.now();
1335
1511
  let assertions = [];
1512
+ const noResponse = !turn.expectSilence && turnData.messages.length === 0;
1513
+ if (noResponse) {
1514
+ assertions.push({
1515
+ assertion: "response",
1516
+ pass: false,
1517
+ expected: "Bot produces a response",
1518
+ actual: `Turn ${i + 1}: bot produced no response.`
1519
+ });
1520
+ }
1336
1521
  if (turn.expectSilence) {
1337
1522
  const wasSilent = turnData.messages.length === 0;
1338
1523
  assertions.push({
@@ -1342,7 +1527,7 @@ async function runEval(evalDef, connection, options = {}) {
1342
1527
  actual: wasSilent ? "No response" : `Bot responded: "${botResponse}"`
1343
1528
  });
1344
1529
  }
1345
- if (turn.assert?.response) {
1530
+ if (turn.assert?.response && !noResponse) {
1346
1531
  assertions = await gradeResponse(botResponse, turn.assert.response, {
1347
1532
  userMessage: turnLabel,
1348
1533
  ...judgePassThreshold !== void 0 ? { judgePassThreshold } : {}
@@ -1376,7 +1561,7 @@ async function runEval(evalDef, connection, options = {}) {
1376
1561
  const turnPass = assertions.every((a) => a.pass);
1377
1562
  const evalDuration = Date.now() - evalStart;
1378
1563
  const turnReport = {
1379
- turnNumber: i + 1,
1564
+ turnIndex: i,
1380
1565
  userMessage: turnLabel,
1381
1566
  botResponse,
1382
1567
  assertions,
@@ -1385,11 +1570,11 @@ async function runEval(evalDef, connection, options = {}) {
1385
1570
  evalDuration
1386
1571
  };
1387
1572
  turns.push(turnReport);
1388
- options.onProgress?.({
1573
+ await options.onProgress?.({
1389
1574
  type: "turn_complete",
1390
1575
  evalName: evalDef.name,
1391
1576
  evalIndex: options.evalIndex ?? 0,
1392
- turnNumber: i + 1,
1577
+ turnIndex: i,
1393
1578
  totalTurns: evalDef.conversation.length,
1394
1579
  turnReport
1395
1580
  });
@@ -1434,13 +1619,27 @@ async function runEval(evalDef, connection, options = {}) {
1434
1619
  ...aborted ? { error: "Eval aborted" } : {}
1435
1620
  };
1436
1621
  } catch (err) {
1622
+ const adkErr = isAdkError(err) ? err : void 0;
1623
+ if (!adkErr || !adkErr.expected) {
1624
+ const errMsg = err.message ?? String(err);
1625
+ const errStack = err.stack ?? "";
1626
+ logger.error(`Eval "${evalDef.name}" failed unexpectedly: ${errMsg}`);
1627
+ logger.error(` stack: ${errStack}`);
1628
+ logger.error(` eval: ${evalDef.name}, turn: ${turns.length}/${evalDef.conversation.length}`);
1629
+ logger.error(` connection: botId=${connection.botId}`);
1630
+ logger.error(
1631
+ ` spanSource: ${options.spanSource?.constructor?.name ?? "default"}, devServerUrl: ${options.devServerUrl ?? "n/a"}`
1632
+ );
1633
+ options.onException?.(err, { source: "evals", eval_name: evalDef.name });
1634
+ }
1437
1635
  return {
1438
1636
  ...evalMetadata(evalDef),
1439
1637
  turns,
1440
1638
  outcomeAssertions,
1441
1639
  pass: false,
1442
1640
  duration: Date.now() - start,
1443
- error: err.message
1641
+ error: err.message,
1642
+ ...adkErr ? { errorCode: adkErr.code } : {}
1444
1643
  };
1445
1644
  } finally {
1446
1645
  collector.disconnect();
@@ -1448,9 +1647,15 @@ async function runEval(evalDef, connection, options = {}) {
1448
1647
  }
1449
1648
  async function runEvalSuite(config, filter) {
1450
1649
  const start = Date.now();
1451
- const runId = randomUUID().replace(/-/g, "").slice(0, 26);
1452
- const evalsDir = `${config.agentPath}/evals`;
1453
- const allEvals = await loadEvalsFromDir(evalsDir);
1650
+ const runId = config.runId ?? randomUUID().replace(/-/g, "").slice(0, 26);
1651
+ let allEvals;
1652
+ if (config.definitions) {
1653
+ allEvals = config.definitions;
1654
+ } else if (config.agentPath) {
1655
+ allEvals = await loadEvalsFromDir(`${config.agentPath}/evals`);
1656
+ } else {
1657
+ throw new Error("Either `definitions` or `agentPath` must be provided in EvalRunnerConfig");
1658
+ }
1454
1659
  const evals = filterEvals(allEvals, filter);
1455
1660
  if (evals.length === 0) {
1456
1661
  return {
@@ -1464,37 +1669,26 @@ async function runEvalSuite(config, filter) {
1464
1669
  ...filter !== void 0 ? { filter } : {}
1465
1670
  };
1466
1671
  }
1467
- await initLLMJudge(
1468
- {
1469
- token: config.credentials.token,
1470
- apiUrl: config.credentials.apiUrl,
1471
- botId: config.credentials.botId,
1472
- ...config.credentials.workspaceId !== void 0 ? { workspaceId: config.credentials.workspaceId } : {}
1473
- },
1474
- {
1475
- ...config.evalOptions?.judgeModel !== void 0 ? { model: config.evalOptions.judgeModel } : {},
1476
- ...config.logger !== void 0 ? { logger: config.logger } : {}
1477
- }
1478
- );
1479
- let webhookId = config.credentials.webhookId;
1480
- if (!webhookId) {
1481
- webhookId = await discoverWebhookId(config.credentials.botId, config.credentials.token, config.credentials.apiUrl);
1482
- }
1672
+ await initLLMJudge(config.client, {
1673
+ ...config.evalOptions?.judgeModel !== void 0 ? { model: config.evalOptions.judgeModel } : {},
1674
+ ...config.logger !== void 0 ? { logger: config.logger } : {}
1675
+ });
1483
1676
  const connection = {
1484
- webhookId,
1485
- botId: config.credentials.botId,
1486
- token: config.credentials.token,
1487
- apiUrl: config.credentials.apiUrl
1677
+ client: config.client,
1678
+ botId: config.botId
1488
1679
  };
1489
1680
  const devServerUrl = config.devServerUrl || DEFAULT_DEV_SERVER_URL;
1490
1681
  const devServerHeaders = config.devServerHeaders ?? {};
1491
- await assertChatChannelBound(devServerUrl, devServerHeaders);
1682
+ if (!config.createSpanSource) {
1683
+ await assertChatChannelBound(devServerUrl, devServerHeaders, config.logger);
1684
+ await assertTraceStreamReadable(devServerUrl, devServerHeaders);
1685
+ }
1492
1686
  const reports = [];
1493
- config.onProgress?.({ type: "suite_start", totalEvals: evals.length });
1687
+ await config.onProgress?.({ type: "suite_start", totalEvals: evals.length });
1494
1688
  for (let i = 0; i < evals.length; i++) {
1495
1689
  if (config.signal?.aborted) break;
1496
1690
  const evalDef = evals[i];
1497
- config.onProgress?.({
1691
+ await config.onProgress?.({
1498
1692
  type: "eval_start",
1499
1693
  evalName: evalDef.name,
1500
1694
  index: i,
@@ -1508,10 +1702,15 @@ async function runEvalSuite(config, filter) {
1508
1702
  ...config.evalOptions?.judgePassThreshold !== void 0 ? { judgePassThreshold: config.evalOptions.judgePassThreshold } : {},
1509
1703
  ...config.onProgress !== void 0 ? { onProgress: config.onProgress } : {},
1510
1704
  ...config.signal !== void 0 ? { signal: config.signal } : {},
1511
- ...config.chatClient !== void 0 ? { chatClient: config.chatClient } : {}
1705
+ ...config.chatClient !== void 0 ? { chatClient: config.chatClient } : {},
1706
+ ...config.logger !== void 0 ? { logger: config.logger } : {},
1707
+ ...config.onException !== void 0 ? { onException: config.onException } : {},
1708
+ ...config.createSpanSource !== void 0 ? { spanSource: config.createSpanSource() } : {},
1709
+ ...config.chatWebhookId !== void 0 ? { chatWebhookId: config.chatWebhookId } : {},
1710
+ ...config.chatBaseUrl !== void 0 ? { chatBaseUrl: config.chatBaseUrl } : {}
1512
1711
  });
1513
1712
  reports.push(report);
1514
- config.onProgress?.({ type: "eval_complete", evalName: evalDef.name, index: i, report });
1713
+ await config.onProgress?.({ type: "eval_complete", evalName: evalDef.name, index: i, report });
1515
1714
  }
1516
1715
  const runReport = {
1517
1716
  id: runId,
@@ -1526,7 +1725,7 @@ async function runEvalSuite(config, filter) {
1526
1725
  if (config.signal?.aborted) {
1527
1726
  runReport.aborted = true;
1528
1727
  }
1529
- config.onProgress?.({ type: "suite_complete", report: runReport });
1728
+ await config.onProgress?.({ type: "suite_complete", report: runReport });
1530
1729
  return runReport;
1531
1730
  }
1532
1731
  export {