@botpress/evals 1.19.0-beta.6 → 1.19.0-beta.9

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,84 @@
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
7
82
  var defaultChatClient = null;
8
83
  function getDefaultChatClient() {
9
84
  if (defaultChatClient) return defaultChatClient;
@@ -11,7 +86,10 @@ function getDefaultChatClient() {
11
86
  const mod = require2("@botpress/chat");
12
87
  const ChatClient = mod.default?.Client ?? mod.Client;
13
88
  if (!ChatClient) {
14
- throw new Error("@botpress/chat did not expose a Client export. Pass EvalRunnerConfig.chatClient explicitly.");
89
+ throw new EvalRunnerError({
90
+ code: "CHAT_CLIENT_MISSING",
91
+ message: "@botpress/chat did not expose a Client export. Pass EvalRunnerConfig.chatClient explicitly."
92
+ });
15
93
  }
16
94
  return ChatClient;
17
95
  }
@@ -26,18 +104,23 @@ var ChatSession = class {
26
104
  const ChatClient = this.chatClient ?? getDefaultChatClient();
27
105
  this.client = await ChatClient.connect({ webhookId: this.webhookId });
28
106
  }
29
- get userId() {
107
+ /** Invariant guard — calling any session method before connect() is a runner bug. */
108
+ assertConnected() {
30
109
  if (!this.client) {
31
- throw new Error("ChatSession not connected. Call connect() first.");
110
+ throw new EvalRunnerError({
111
+ code: "CHAT_NOT_CONNECTED",
112
+ message: "ChatSession not connected. Call connect() first."
113
+ });
32
114
  }
33
- return this.client.user.id;
115
+ return this.client;
116
+ }
117
+ get userId() {
118
+ return this.assertConnected().user.id;
34
119
  }
35
120
  async ensureConversation() {
36
- if (!this.client) {
37
- throw new Error("ChatSession not connected. Call connect() first.");
38
- }
121
+ const client = this.assertConnected();
39
122
  if (!this.conversationId) {
40
- const conv = await this.client.createConversation({});
123
+ const conv = await client.createConversation({});
41
124
  this.conversationId = conv.conversation.id;
42
125
  return conv.conversation.id;
43
126
  }
@@ -47,21 +130,17 @@ var ChatSession = class {
47
130
  * Send a user message. Fire-and-forget — observation is via SSECollector.
48
131
  */
49
132
  async sendMessage(message) {
50
- if (!this.client) {
51
- throw new Error("ChatSession not connected. Call connect() first.");
52
- }
133
+ const client = this.assertConnected();
53
134
  const conversationId = await this.ensureConversation();
54
- await this.client.createMessage({ conversationId, payload: { type: "text", text: message } });
135
+ await client.createMessage({ conversationId, payload: { type: "text", text: message } });
55
136
  }
56
137
  /**
57
138
  * Send an event. Fire-and-forget — observation is via SSECollector.
58
139
  */
59
140
  async sendEvent(type, payload) {
60
- if (!this.client) {
61
- throw new Error("ChatSession not connected. Call connect() first.");
62
- }
141
+ const client = this.assertConnected();
63
142
  const conversationId = await this.ensureConversation();
64
- await this.client.createEvent({ type, payload, conversationId });
143
+ await client.createEvent({ type, payload, conversationId });
65
144
  }
66
145
  };
67
146
  async function discoverWebhookId(botId, token, apiUrl) {
@@ -71,26 +150,31 @@ async function discoverWebhookId(botId, token, apiUrl) {
71
150
  const chat = Object.values(integrations).find((int) => int.name === "chat");
72
151
  const webhookId = chat?.webhookId;
73
152
  if (!webhookId || typeof webhookId !== "string") {
74
- throw new Error(
75
- [
153
+ throw new EvalRunnerError({
154
+ code: "CHAT_INTEGRATION_MISSING",
155
+ message: [
76
156
  "The `chat` integration is not installed on this bot \u2014 evals require it.",
77
157
  "",
78
158
  "To fix:",
79
159
  " 1. Install it: adk add chat",
80
160
  " 2. Redeploy: adk dev (or: adk deploy)"
81
- ].join("\n")
82
- );
161
+ ].join("\n"),
162
+ expected: true
163
+ });
83
164
  }
84
165
  return webhookId;
85
166
  }
86
- async function assertChatChannelBound(devServerUrl, headers = {}) {
167
+ async function assertChatChannelBound(devServerUrl, headers = {}, logger = defaultLogger) {
87
168
  let conversations = [];
88
169
  try {
89
170
  const res = await fetch(`${devServerUrl}/api/agent`, { headers });
90
171
  if (!res.ok) return;
91
172
  const agent = await res.json();
92
173
  conversations = agent.conversations ?? [];
93
- } catch {
174
+ } catch (err) {
175
+ logger?.warn(
176
+ `Skipping chat.channel binding pre-flight \u2014 could not query ${devServerUrl}/api/agent: ${err instanceof Error ? err.message : String(err)}`
177
+ );
94
178
  return;
95
179
  }
96
180
  if (conversations.length === 0) return;
@@ -101,8 +185,9 @@ async function assertChatChannelBound(devServerUrl, headers = {}) {
101
185
  };
102
186
  const bound = conversations.some((c) => matchesChatChannel(c.channel));
103
187
  if (bound) return;
104
- throw new Error(
105
- [
188
+ throw new EvalRunnerError({
189
+ code: "CHAT_CHANNEL_UNBOUND",
190
+ message: [
106
191
  "No Conversation is bound to `chat.channel` \u2014 messages via `@botpress/chat` will time out silently.",
107
192
  "",
108
193
  "To fix, bind a Conversation in `src/conversations/` to `chat.channel` (or `*`):",
@@ -111,8 +196,9 @@ async function assertChatChannelBound(devServerUrl, headers = {}) {
111
196
  " channel: ['chat.channel', ...], // array form",
112
197
  "",
113
198
  "The `chat` integration is how evals and `adk chat --single` send messages programmatically."
114
- ].join("\n")
115
- );
199
+ ].join("\n"),
200
+ expected: true
201
+ });
116
202
  }
117
203
 
118
204
  // src/sse-collector.ts
@@ -159,10 +245,15 @@ var SSECollector = class _SSECollector {
159
245
  signal: this.abortController.signal
160
246
  });
161
247
  if (!response.ok) {
162
- throw new Error(`SSE connection failed: ${response.status} ${response.statusText}`);
248
+ throw new EvalRunnerError({
249
+ code: "SSE_CONNECT_FAILED",
250
+ message: `SSE connection failed: ${response.status} ${response.statusText}`,
251
+ expected: true,
252
+ suggestion: "Make sure the dev server is running (`adk dev`) and reachable."
253
+ });
163
254
  }
164
255
  if (!response.body) {
165
- throw new Error("SSE response has no body");
256
+ throw new EvalRunnerError({ code: "SSE_NO_BODY", message: "SSE response has no body" });
166
257
  }
167
258
  this.connected = true;
168
259
  this._consumeStream(response.body).catch((err) => {
@@ -628,13 +719,15 @@ var MODEL_ALIASES = ["fast", "best"];
628
719
  var _cognitive = null;
629
720
  var _availableModels = [];
630
721
  var _judgeModel = "fast";
722
+ var _log = defaultLogger;
631
723
  function _isModelValid(model) {
632
724
  if (MODEL_ALIASES.includes(model)) return true;
633
725
  if (_availableModels.length === 0) return false;
634
726
  return _availableModels.includes(model);
635
727
  }
636
728
  async function initLLMJudge(credentials, options) {
637
- const log = options?.logger ?? console;
729
+ const log = options?.logger ?? defaultLogger;
730
+ _log = log;
638
731
  const client = new Client({
639
732
  token: credentials.token,
640
733
  apiUrl: credentials.apiUrl,
@@ -659,11 +752,12 @@ async function gradeLLMJudge(botResponse, criteria, context) {
659
752
  const threshold = Math.max(1, Math.min(5, context.passThreshold ?? DEFAULT_PASS_THRESHOLD));
660
753
  try {
661
754
  if (!_cognitive) {
755
+ _log.warn(`LLM judge unavailable for "${criteria}": no credentials configured (initLLMJudge not called)`);
662
756
  return {
663
757
  assertion: `llm_judge: "${criteria}"`,
664
- pass: true,
758
+ pass: false,
665
759
  expected: criteria,
666
- actual: "SKIPPED \u2014 LLM judge unavailable: no credentials configured"
760
+ actual: "FAILED \u2014 LLM judge unavailable: no credentials configured"
667
761
  };
668
762
  }
669
763
  if (!_isModelValid(_judgeModel)) {
@@ -693,11 +787,12 @@ Criteria: ${criteria}`
693
787
  const rawContent = output?.choices?.[0]?.content;
694
788
  const content = typeof rawContent === "string" ? rawContent : null;
695
789
  if (!content) {
790
+ _log.warn(`LLM judge returned an empty response for "${criteria}"`);
696
791
  return {
697
792
  assertion: `llm_judge: "${criteria}"`,
698
- pass: true,
793
+ pass: false,
699
794
  expected: criteria,
700
- actual: "SKIPPED \u2014 LLM judge returned empty response"
795
+ actual: "FAILED \u2014 LLM judge returned empty response"
701
796
  };
702
797
  }
703
798
  const verdict = JSON.parse(content);
@@ -708,11 +803,12 @@ Criteria: ${criteria}`
708
803
  actual: `Score ${verdict.score}/5 (threshold: ${threshold}) \u2014 ${verdict.reason}`
709
804
  };
710
805
  } catch (err) {
806
+ _log.warn(`LLM judge failed for "${criteria}": ${err.message}`);
711
807
  return {
712
808
  assertion: `llm_judge: "${criteria}"`,
713
- pass: true,
809
+ pass: false,
714
810
  expected: criteria,
715
- actual: `SKIPPED \u2014 LLM judge unavailable: ${err.message}`
811
+ actual: `FAILED \u2014 LLM judge unavailable: ${err.message}`
716
812
  };
717
813
  }
718
814
  }
@@ -1053,30 +1149,6 @@ function gradeOutcome(mutations, evalDef, workflowSpans) {
1053
1149
  // src/loader.ts
1054
1150
  import { readdirSync, existsSync } from "fs";
1055
1151
  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
1152
  function isEvalDefinition(value) {
1081
1153
  return value !== null && typeof value === "object" && typeof value.name === "string" && value.name !== "" && Array.isArray(value.conversation);
1082
1154
  }
@@ -1087,15 +1159,17 @@ async function loadEvalFile(filePath) {
1087
1159
  mod = await import(absPath);
1088
1160
  } catch (err) {
1089
1161
  const msg = err instanceof Error ? err.message : String(err);
1090
- throw new Error(
1091
- `Failed to load eval file ${filePath}: ${msg}
1162
+ throw new EvalRunnerError({
1163
+ code: "EVAL_LOAD_FAILED",
1164
+ message: `Failed to load eval file ${filePath}: ${msg}
1092
1165
 
1093
1166
  Make sure your eval file:
1094
1167
  - Has no syntax or type errors
1095
1168
  - Exports one or more \`new Eval({...})\` instances
1096
1169
  - Has all dependencies installed (\`bun install\`)`,
1097
- { cause: err }
1098
- );
1170
+ expected: true,
1171
+ cause: err
1172
+ });
1099
1173
  }
1100
1174
  const results = [];
1101
1175
  for (const [key, value] of Object.entries(mod)) {
@@ -1105,9 +1179,11 @@ Make sure your eval file:
1105
1179
  }
1106
1180
  }
1107
1181
  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
- );
1182
+ throw new EvalRunnerError({
1183
+ code: "EVAL_FILE_EMPTY",
1184
+ message: `Invalid eval file ${filePath}: no valid evals found. Export one or more \`new Eval({...})\` instances (as default or named exports).`,
1185
+ expected: true
1186
+ });
1111
1187
  }
1112
1188
  return results;
1113
1189
  }
@@ -1125,7 +1201,12 @@ async function loadEvalsFromDir(dirPath) {
1125
1201
  const seen = /* @__PURE__ */ new Set();
1126
1202
  for (const e of evals) {
1127
1203
  if (seen.has(e.name)) {
1128
- throw new Error(`Duplicate eval name "${e.name}" found in ${dirPath}`);
1204
+ throw new EvalRunnerError({
1205
+ code: "EVAL_DUPLICATE_NAME",
1206
+ message: `Duplicate eval name "${e.name}" found in ${dirPath} \u2014 names must be unique across the evals directory.`,
1207
+ expected: true,
1208
+ details: { name: e.name }
1209
+ });
1129
1210
  }
1130
1211
  seen.add(e.name);
1131
1212
  }
@@ -1189,7 +1270,10 @@ async function seedEvalState(client, ctx, setup) {
1189
1270
  }
1190
1271
  if (state.conversation) {
1191
1272
  if (!ctx.conversationId) {
1192
- throw new Error("Cannot seed conversation state before a conversation is created.");
1273
+ throw new EvalRunnerError({
1274
+ code: "EVAL_SEED_NO_CONVERSATION",
1275
+ message: "Cannot seed conversation state before a conversation is created."
1276
+ });
1193
1277
  }
1194
1278
  writes.push(
1195
1279
  client.setState({
@@ -1225,6 +1309,7 @@ function evalMetadata(evalDef) {
1225
1309
  async function runEval(evalDef, connection, options = {}) {
1226
1310
  const devServerUrl = options.devServerUrl || DEFAULT_DEV_SERVER_URL;
1227
1311
  const devServerHeaders = options.devServerHeaders ?? {};
1312
+ const logger = options.logger ?? defaultLogger;
1228
1313
  const idleTimeout = evalDef.options?.idleTimeout ?? options.idleTimeout ?? DEFAULT_IDLE_TIMEOUT;
1229
1314
  const judgePassThreshold = evalDef.options?.judgePassThreshold ?? options.judgePassThreshold;
1230
1315
  const start = Date.now();
@@ -1272,19 +1357,28 @@ async function runEval(evalDef, connection, options = {}) {
1272
1357
  if (conversationId) {
1273
1358
  await collector.connect({ conversationId });
1274
1359
  } 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.");
1360
+ throw new EvalRunnerError({
1361
+ code: "EVAL_NO_CONVERSATION_ID",
1362
+ message: "Cannot run conversation eval without a conversationId \u2014 no chat session or setup created one."
1363
+ });
1276
1364
  }
1277
1365
  for (let i = 0; i < evalDef.conversation.length; i++) {
1278
1366
  if (options.signal?.aborted) break;
1279
1367
  const turn = evalDef.conversation[i];
1368
+ const turnConfigError = (message) => new EvalRunnerError({
1369
+ code: "EVAL_TURN_CONFIG_INVALID",
1370
+ message: `Turn ${i + 1}: ${message}`,
1371
+ expected: true,
1372
+ details: { turn: i + 1 }
1373
+ });
1280
1374
  if (turn.expectSilence && turn.assert?.response) {
1281
- throw new Error(`Turn ${i + 1}: 'expectSilence' and 'assert.response' are mutually exclusive.`);
1375
+ throw turnConfigError(`'expectSilence' and 'assert.response' are mutually exclusive.`);
1282
1376
  }
1283
1377
  if (turn.user && turn.event) {
1284
- throw new Error(`Turn ${i + 1}: 'user' and 'event' are mutually exclusive.`);
1378
+ throw turnConfigError(`'user' and 'event' are mutually exclusive.`);
1285
1379
  }
1286
1380
  if (!turn.user && !turn.event) {
1287
- throw new Error(`Turn ${i + 1}: must have either 'user' or 'event'.`);
1381
+ throw turnConfigError(`must have either 'user' or 'event'.`);
1288
1382
  }
1289
1383
  const turnLabelForEvent = turn.event ? `[event: ${turn.event.type}]` : turn.user;
1290
1384
  options.onProgress?.({
@@ -1328,11 +1422,17 @@ async function runEval(evalDef, connection, options = {}) {
1328
1422
  const botResponse = turnData.messages.join("\n");
1329
1423
  const botDuration = turnData.handlerDuration;
1330
1424
  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
1425
  const evalStart = Date.now();
1335
1426
  let assertions = [];
1427
+ const noResponse = !turn.expectSilence && turnData.messages.length === 0;
1428
+ if (noResponse) {
1429
+ assertions.push({
1430
+ assertion: "response",
1431
+ pass: false,
1432
+ expected: "Bot produces a response",
1433
+ actual: `Turn ${i + 1}: bot produced no response.`
1434
+ });
1435
+ }
1336
1436
  if (turn.expectSilence) {
1337
1437
  const wasSilent = turnData.messages.length === 0;
1338
1438
  assertions.push({
@@ -1342,7 +1442,7 @@ async function runEval(evalDef, connection, options = {}) {
1342
1442
  actual: wasSilent ? "No response" : `Bot responded: "${botResponse}"`
1343
1443
  });
1344
1444
  }
1345
- if (turn.assert?.response) {
1445
+ if (turn.assert?.response && !noResponse) {
1346
1446
  assertions = await gradeResponse(botResponse, turn.assert.response, {
1347
1447
  userMessage: turnLabel,
1348
1448
  ...judgePassThreshold !== void 0 ? { judgePassThreshold } : {}
@@ -1434,13 +1534,19 @@ async function runEval(evalDef, connection, options = {}) {
1434
1534
  ...aborted ? { error: "Eval aborted" } : {}
1435
1535
  };
1436
1536
  } catch (err) {
1537
+ const adkErr = isAdkError(err) ? err : void 0;
1538
+ if (!adkErr || !adkErr.expected) {
1539
+ logger.error(`Eval "${evalDef.name}" failed unexpectedly: ${err.message}`);
1540
+ options.onException?.(err, { source: "evals", eval_name: evalDef.name });
1541
+ }
1437
1542
  return {
1438
1543
  ...evalMetadata(evalDef),
1439
1544
  turns,
1440
1545
  outcomeAssertions,
1441
1546
  pass: false,
1442
1547
  duration: Date.now() - start,
1443
- error: err.message
1548
+ error: err.message,
1549
+ ...adkErr ? { errorCode: adkErr.code } : {}
1444
1550
  };
1445
1551
  } finally {
1446
1552
  collector.disconnect();
@@ -1488,7 +1594,7 @@ async function runEvalSuite(config, filter) {
1488
1594
  };
1489
1595
  const devServerUrl = config.devServerUrl || DEFAULT_DEV_SERVER_URL;
1490
1596
  const devServerHeaders = config.devServerHeaders ?? {};
1491
- await assertChatChannelBound(devServerUrl, devServerHeaders);
1597
+ await assertChatChannelBound(devServerUrl, devServerHeaders, config.logger);
1492
1598
  const reports = [];
1493
1599
  config.onProgress?.({ type: "suite_start", totalEvals: evals.length });
1494
1600
  for (let i = 0; i < evals.length; i++) {
@@ -1508,7 +1614,9 @@ async function runEvalSuite(config, filter) {
1508
1614
  ...config.evalOptions?.judgePassThreshold !== void 0 ? { judgePassThreshold: config.evalOptions.judgePassThreshold } : {},
1509
1615
  ...config.onProgress !== void 0 ? { onProgress: config.onProgress } : {},
1510
1616
  ...config.signal !== void 0 ? { signal: config.signal } : {},
1511
- ...config.chatClient !== void 0 ? { chatClient: config.chatClient } : {}
1617
+ ...config.chatClient !== void 0 ? { chatClient: config.chatClient } : {},
1618
+ ...config.logger !== void 0 ? { logger: config.logger } : {},
1619
+ ...config.onException !== void 0 ? { onException: config.onException } : {}
1512
1620
  });
1513
1621
  reports.push(report);
1514
1622
  config.onProgress?.({ type: "eval_complete", evalName: evalDef.name, index: i, report });