@botpress/evals 1.18.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client.d.ts +50 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +123 -0
- package/dist/client.js.map +7 -0
- package/dist/definition.d.ts +113 -0
- package/dist/definition.d.ts.map +1 -0
- package/dist/definition.js +25 -0
- package/dist/definition.js.map +7 -0
- package/dist/graders/index.d.ts +12 -0
- package/dist/graders/index.d.ts.map +1 -0
- package/dist/graders/index.js +466 -0
- package/dist/graders/index.js.map +7 -0
- package/dist/graders/llm.d.ts +23 -0
- package/dist/graders/llm.d.ts.map +1 -0
- package/dist/graders/llm.js +125 -0
- package/dist/graders/llm.js.map +7 -0
- package/dist/graders/match.d.ts +11 -0
- package/dist/graders/match.d.ts.map +1 -0
- package/dist/graders/match.js +48 -0
- package/dist/graders/match.js.map +7 -0
- package/dist/graders/outcome.d.ts +11 -0
- package/dist/graders/outcome.d.ts.map +1 -0
- package/dist/graders/outcome.js +148 -0
- package/dist/graders/outcome.js.map +7 -0
- package/dist/graders/response.d.ts +10 -0
- package/dist/graders/response.d.ts.map +1 -0
- package/dist/graders/response.js +155 -0
- package/dist/graders/response.js.map +7 -0
- package/dist/graders/state.d.ts +16 -0
- package/dist/graders/state.d.ts.map +1 -0
- package/dist/graders/state.js +101 -0
- package/dist/graders/state.js.map +7 -0
- package/dist/graders/timing.d.ts +7 -0
- package/dist/graders/timing.d.ts.map +1 -0
- package/dist/graders/timing.js +63 -0
- package/dist/graders/timing.js.map +7 -0
- package/dist/graders/tools.d.ts +7 -0
- package/dist/graders/tools.d.ts.map +1 -0
- package/dist/graders/tools.js +122 -0
- package/dist/graders/tools.js.map +7 -0
- package/dist/graders/workflow.d.ts +7 -0
- package/dist/graders/workflow.d.ts.map +1 -0
- package/dist/graders/workflow.js +34 -0
- package/dist/graders/workflow.js.map +7 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +7 -0
- package/dist/loader.d.ts +11 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +114 -0
- package/dist/loader.js.map +7 -0
- package/dist/runner.d.ts +25 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +1536 -0
- package/dist/runner.js.map +7 -0
- package/dist/sse-collector.d.ts +148 -0
- package/dist/sse-collector.d.ts.map +1 -0
- package/dist/sse-collector.js +386 -0
- package/dist/sse-collector.js.map +7 -0
- package/dist/trace.d.ts +56 -0
- package/dist/trace.d.ts.map +1 -0
- package/dist/trace.js +1 -0
- package/dist/trace.js.map +7 -0
- package/dist/transformer.d.ts +18 -0
- package/dist/transformer.d.ts.map +1 -0
- package/dist/transformer.js +101 -0
- package/dist/transformer.js.map +7 -0
- package/dist/types.d.ts +184 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +25 -0
- package/dist/types.js.map +7 -0
- package/package.json +99 -0
package/dist/runner.js
ADDED
|
@@ -0,0 +1,1536 @@
|
|
|
1
|
+
// src/runner.ts
|
|
2
|
+
import { Client as BpClient2 } from "@botpress/client";
|
|
3
|
+
|
|
4
|
+
// 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
|
+
}
|
|
18
|
+
var ChatSession = class {
|
|
19
|
+
constructor(webhookId, chatClient) {
|
|
20
|
+
this.webhookId = webhookId;
|
|
21
|
+
this.chatClient = chatClient;
|
|
22
|
+
}
|
|
23
|
+
client = null;
|
|
24
|
+
conversationId = null;
|
|
25
|
+
async connect() {
|
|
26
|
+
const ChatClient = this.chatClient ?? getDefaultChatClient();
|
|
27
|
+
this.client = await ChatClient.connect({ webhookId: this.webhookId });
|
|
28
|
+
}
|
|
29
|
+
get userId() {
|
|
30
|
+
if (!this.client) {
|
|
31
|
+
throw new Error("ChatSession not connected. Call connect() first.");
|
|
32
|
+
}
|
|
33
|
+
return this.client.user.id;
|
|
34
|
+
}
|
|
35
|
+
async ensureConversation() {
|
|
36
|
+
if (!this.client) {
|
|
37
|
+
throw new Error("ChatSession not connected. Call connect() first.");
|
|
38
|
+
}
|
|
39
|
+
if (!this.conversationId) {
|
|
40
|
+
const conv = await this.client.createConversation({});
|
|
41
|
+
this.conversationId = conv.conversation.id;
|
|
42
|
+
return conv.conversation.id;
|
|
43
|
+
}
|
|
44
|
+
return this.conversationId;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Send a user message. Fire-and-forget — observation is via SSECollector.
|
|
48
|
+
*/
|
|
49
|
+
async sendMessage(message) {
|
|
50
|
+
if (!this.client) {
|
|
51
|
+
throw new Error("ChatSession not connected. Call connect() first.");
|
|
52
|
+
}
|
|
53
|
+
const conversationId = await this.ensureConversation();
|
|
54
|
+
await this.client.createMessage({ conversationId, payload: { type: "text", text: message } });
|
|
55
|
+
}
|
|
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
|
+
}
|
|
63
|
+
const conversationId = await this.ensureConversation();
|
|
64
|
+
await this.client.createEvent({ type, payload, conversationId });
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
async function discoverWebhookId(botId, token, apiUrl) {
|
|
68
|
+
const client = new BpClient({ token, botId, apiUrl });
|
|
69
|
+
const { bot } = await client.getBot({ id: botId });
|
|
70
|
+
const integrations = bot.integrations || {};
|
|
71
|
+
const chat = Object.values(integrations).find((int) => int.name === "chat");
|
|
72
|
+
const webhookId = chat?.webhookId;
|
|
73
|
+
if (!webhookId || typeof webhookId !== "string") {
|
|
74
|
+
throw new Error(
|
|
75
|
+
[
|
|
76
|
+
"The `chat` integration is not installed on this bot \u2014 evals require it.",
|
|
77
|
+
"",
|
|
78
|
+
"To fix:",
|
|
79
|
+
" 1. Install it: adk add chat",
|
|
80
|
+
" 2. Redeploy: adk dev (or: adk deploy)"
|
|
81
|
+
].join("\n")
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
return webhookId;
|
|
85
|
+
}
|
|
86
|
+
async function assertChatChannelBound(devServerUrl, headers = {}) {
|
|
87
|
+
let conversations = [];
|
|
88
|
+
try {
|
|
89
|
+
const res = await fetch(`${devServerUrl}/api/agent`, { headers });
|
|
90
|
+
if (!res.ok) return;
|
|
91
|
+
const agent = await res.json();
|
|
92
|
+
conversations = agent.conversations ?? [];
|
|
93
|
+
} catch {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (conversations.length === 0) return;
|
|
97
|
+
const matchesChatChannel = (ch) => {
|
|
98
|
+
if (!ch) return false;
|
|
99
|
+
if (ch === "*" || ch === "chat.channel") return true;
|
|
100
|
+
return Array.isArray(ch) && (ch.includes("chat.channel") || ch.includes("*"));
|
|
101
|
+
};
|
|
102
|
+
const bound = conversations.some((c) => matchesChatChannel(c.channel));
|
|
103
|
+
if (bound) return;
|
|
104
|
+
throw new Error(
|
|
105
|
+
[
|
|
106
|
+
"No Conversation is bound to `chat.channel` \u2014 messages via `@botpress/chat` will time out silently.",
|
|
107
|
+
"",
|
|
108
|
+
"To fix, bind a Conversation in `src/conversations/` to `chat.channel` (or `*`):",
|
|
109
|
+
" channel: 'chat.channel', // explicit",
|
|
110
|
+
" channel: '*', // wildcard \u2014 matches every channel",
|
|
111
|
+
" channel: ['chat.channel', ...], // array form",
|
|
112
|
+
"",
|
|
113
|
+
"The `chat` integration is how evals and `adk chat --single` send messages programmatically."
|
|
114
|
+
].join("\n")
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// src/sse-collector.ts
|
|
119
|
+
function spanKey(span) {
|
|
120
|
+
return `${span.id.trace}:${span.id.span}`;
|
|
121
|
+
}
|
|
122
|
+
function spanData(span) {
|
|
123
|
+
return span.data && typeof span.data === "object" ? span.data : {};
|
|
124
|
+
}
|
|
125
|
+
var SSECollector = class _SSECollector {
|
|
126
|
+
devServerUrl;
|
|
127
|
+
headers;
|
|
128
|
+
/** All spans accumulated since connect(), keyed by spanKey for dedup. */
|
|
129
|
+
spanMap = /* @__PURE__ */ new Map();
|
|
130
|
+
/** Index into spanMap insertion order marking where the current turn started. */
|
|
131
|
+
turnStartKeys = /* @__PURE__ */ new Set();
|
|
132
|
+
/** Handler span IDs seen before the current turn (for startTurn filtering). */
|
|
133
|
+
seenHandlerKeys = /* @__PURE__ */ new Set();
|
|
134
|
+
/** Callbacks waiting for span updates. */
|
|
135
|
+
waiters = [];
|
|
136
|
+
/** AbortController for the active SSE connection. */
|
|
137
|
+
abortController = null;
|
|
138
|
+
/** Whether the background loop is running. */
|
|
139
|
+
connected = false;
|
|
140
|
+
/** Error from the SSE stream if it died unexpectedly. */
|
|
141
|
+
streamError = null;
|
|
142
|
+
/** Set while a settle phase is in flight; called by disconnect() to short-circuit. */
|
|
143
|
+
_cancelSettle = null;
|
|
144
|
+
constructor(devServerUrl, headers = {}) {
|
|
145
|
+
this.devServerUrl = devServerUrl;
|
|
146
|
+
this.headers = headers;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Open a streaming SSE connection filtered by conversationId or workflowId.
|
|
150
|
+
* Starts a background loop that parses SSE events and accumulates spans.
|
|
151
|
+
*/
|
|
152
|
+
async connect(filter) {
|
|
153
|
+
const attributeName = "conversationId" in filter ? "conversationId" : "workflowId";
|
|
154
|
+
const attributeValue = "conversationId" in filter ? filter.conversationId : filter.workflowId;
|
|
155
|
+
const url = `${this.devServerUrl}/api/traces/stream?attributeName=${encodeURIComponent(attributeName)}&attributeValue=${encodeURIComponent(attributeValue)}`;
|
|
156
|
+
this.abortController = new AbortController();
|
|
157
|
+
const response = await fetch(url, {
|
|
158
|
+
headers: { ...this.headers, Accept: "text/event-stream" },
|
|
159
|
+
signal: this.abortController.signal
|
|
160
|
+
});
|
|
161
|
+
if (!response.ok) {
|
|
162
|
+
throw new Error(`SSE connection failed: ${response.status} ${response.statusText}`);
|
|
163
|
+
}
|
|
164
|
+
if (!response.body) {
|
|
165
|
+
throw new Error("SSE response has no body");
|
|
166
|
+
}
|
|
167
|
+
this.connected = true;
|
|
168
|
+
this._consumeStream(response.body).catch((err) => {
|
|
169
|
+
if (this.connected) {
|
|
170
|
+
this.streamError = err instanceof Error ? err : new Error(String(err));
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
await this._waitForSnapshot();
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* 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.
|
|
179
|
+
*/
|
|
180
|
+
startTurn() {
|
|
181
|
+
this.turnStartKeys = new Set(this.spanMap.keys());
|
|
182
|
+
for (const [key, span] of this.spanMap) {
|
|
183
|
+
if (span.name === "handler.conversation") {
|
|
184
|
+
this.seenHandlerKeys.add(key);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
static SETTLE_MS = 100;
|
|
189
|
+
/**
|
|
190
|
+
* Wait for a handler.conversation span to reach terminal status (ok or error).
|
|
191
|
+
* Only considers spans NOT seen before the last startTurn() call.
|
|
192
|
+
*
|
|
193
|
+
* After the handler closes, waits for a quiet period (no new spans for SETTLE_MS)
|
|
194
|
+
* to ensure all child spans (tool calls, messages, state saves) have arrived.
|
|
195
|
+
* The runtime's HttpSpanExporter fires POSTs without awaiting them, so child
|
|
196
|
+
* spans may arrive slightly after the handler close event.
|
|
197
|
+
*/
|
|
198
|
+
async waitForTurnComplete(opts) {
|
|
199
|
+
if (this._hasNewCompletedHandler()) {
|
|
200
|
+
return this._settleAfterHandler();
|
|
201
|
+
}
|
|
202
|
+
if (opts.abortSignal?.aborted) {
|
|
203
|
+
throw new Error("waitForTurnComplete aborted");
|
|
204
|
+
}
|
|
205
|
+
return new Promise((resolve2, reject) => {
|
|
206
|
+
const timer = setTimeout(() => {
|
|
207
|
+
this._removeWaiter(waiter);
|
|
208
|
+
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}`));
|
|
211
|
+
}, opts.timeout);
|
|
212
|
+
const onAbort = () => {
|
|
213
|
+
clearTimeout(timer);
|
|
214
|
+
this._removeWaiter(waiter);
|
|
215
|
+
reject(new Error("waitForTurnComplete aborted"));
|
|
216
|
+
};
|
|
217
|
+
opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
|
|
218
|
+
const waiter = (_span) => {
|
|
219
|
+
if (this._hasNewCompletedHandler()) {
|
|
220
|
+
clearTimeout(timer);
|
|
221
|
+
this._removeWaiter(waiter);
|
|
222
|
+
opts.abortSignal?.removeEventListener("abort", onAbort);
|
|
223
|
+
this._settleAfterHandler().then(resolve2, reject);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
this.waiters.push(waiter);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
static MAX_SETTLE_MS = 2e3;
|
|
230
|
+
/**
|
|
231
|
+
* After the handler span closes, wait for spans to stop arriving.
|
|
232
|
+
* Resolves when no new spans arrive for SETTLE_MS, or after MAX_SETTLE_MS hard cap.
|
|
233
|
+
*
|
|
234
|
+
* Exposes a cancellation hook via _cancelSettle so disconnect() can short-
|
|
235
|
+
* circuit an in-flight settle phase and avoid leaking timers for up to 2s.
|
|
236
|
+
*/
|
|
237
|
+
_settleAfterHandler() {
|
|
238
|
+
return new Promise((resolve2) => {
|
|
239
|
+
let resolved = false;
|
|
240
|
+
const done = () => {
|
|
241
|
+
if (resolved) return;
|
|
242
|
+
resolved = true;
|
|
243
|
+
clearTimeout(hardCap);
|
|
244
|
+
clearTimeout(settleTimer);
|
|
245
|
+
this._removeWaiter(settleWaiter);
|
|
246
|
+
this._cancelSettle = null;
|
|
247
|
+
resolve2();
|
|
248
|
+
};
|
|
249
|
+
const hardCap = setTimeout(done, _SSECollector.MAX_SETTLE_MS);
|
|
250
|
+
let settleTimer = setTimeout(done, _SSECollector.SETTLE_MS);
|
|
251
|
+
const settleWaiter = () => {
|
|
252
|
+
if (resolved) return;
|
|
253
|
+
clearTimeout(settleTimer);
|
|
254
|
+
settleTimer = setTimeout(done, _SSECollector.SETTLE_MS);
|
|
255
|
+
};
|
|
256
|
+
this.waiters.push(settleWaiter);
|
|
257
|
+
this._cancelSettle = done;
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Wait for a handler.workflow span with matching workflow name.
|
|
262
|
+
* - signal 'entered': resolves when the span appears (any status)
|
|
263
|
+
* - signal 'completed': resolves when the span has status !== 'running'
|
|
264
|
+
*/
|
|
265
|
+
async waitForWorkflow(name, opts) {
|
|
266
|
+
if (this._matchesWorkflowCondition(name, opts.signal)) {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (opts.abortSignal?.aborted) {
|
|
270
|
+
throw new Error(`waitForWorkflow("${name}", { signal: "${opts.signal}" }) aborted`);
|
|
271
|
+
}
|
|
272
|
+
return new Promise((resolve2, reject) => {
|
|
273
|
+
const timer = setTimeout(() => {
|
|
274
|
+
this._removeWaiter(waiter);
|
|
275
|
+
opts.abortSignal?.removeEventListener("abort", onAbort);
|
|
276
|
+
const reason = this.streamError ? `SSE stream died: ${this.streamError.message}` : "span not found";
|
|
277
|
+
reject(
|
|
278
|
+
new Error(
|
|
279
|
+
`waitForWorkflow("${name}", { signal: "${opts.signal}" }) timed out after ${opts.timeout}ms \u2014 ${reason}`
|
|
280
|
+
)
|
|
281
|
+
);
|
|
282
|
+
}, opts.timeout);
|
|
283
|
+
const onAbort = () => {
|
|
284
|
+
clearTimeout(timer);
|
|
285
|
+
this._removeWaiter(waiter);
|
|
286
|
+
reject(new Error(`waitForWorkflow("${name}", { signal: "${opts.signal}" }) aborted`));
|
|
287
|
+
};
|
|
288
|
+
opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
|
|
289
|
+
const waiter = (_span) => {
|
|
290
|
+
if (this._matchesWorkflowCondition(name, opts.signal)) {
|
|
291
|
+
clearTimeout(timer);
|
|
292
|
+
this._removeWaiter(waiter);
|
|
293
|
+
opts.abortSignal?.removeEventListener("abort", onAbort);
|
|
294
|
+
resolve2();
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
this.waiters.push(waiter);
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Return spans accumulated since the last startTurn() call.
|
|
302
|
+
*/
|
|
303
|
+
getTurnSpans() {
|
|
304
|
+
const result = [];
|
|
305
|
+
for (const [key, span] of this.spanMap) {
|
|
306
|
+
if (!this.turnStartKeys.has(key)) {
|
|
307
|
+
result.push(span);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return result;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Return all spans accumulated since connect().
|
|
314
|
+
*/
|
|
315
|
+
getAllSpans() {
|
|
316
|
+
return Array.from(this.spanMap.values());
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Close the SSE connection and clean up.
|
|
320
|
+
*/
|
|
321
|
+
disconnect() {
|
|
322
|
+
this.connected = false;
|
|
323
|
+
this.abortController?.abort();
|
|
324
|
+
this.abortController = null;
|
|
325
|
+
this._cancelSettle?.();
|
|
326
|
+
this._cancelSettle = null;
|
|
327
|
+
this.waiters = [];
|
|
328
|
+
this.snapshotResolvers = [];
|
|
329
|
+
}
|
|
330
|
+
// ── Private ──────────────────────────────────────────────────
|
|
331
|
+
/** Whether we have received the initial snapshot. */
|
|
332
|
+
snapshotReceived = false;
|
|
333
|
+
snapshotResolvers = [];
|
|
334
|
+
static SNAPSHOT_TIMEOUT_MS = 1e4;
|
|
335
|
+
/**
|
|
336
|
+
* Returns a promise that resolves once the snapshot event has been processed,
|
|
337
|
+
* or rejects after a timeout to prevent connect() from hanging indefinitely.
|
|
338
|
+
*/
|
|
339
|
+
_waitForSnapshot() {
|
|
340
|
+
if (this.snapshotReceived) {
|
|
341
|
+
return Promise.resolve();
|
|
342
|
+
}
|
|
343
|
+
return new Promise((resolve2, reject) => {
|
|
344
|
+
let settled = false;
|
|
345
|
+
const resolver = () => {
|
|
346
|
+
if (settled) return;
|
|
347
|
+
settled = true;
|
|
348
|
+
clearTimeout(timer);
|
|
349
|
+
resolve2();
|
|
350
|
+
};
|
|
351
|
+
const timer = setTimeout(() => {
|
|
352
|
+
if (settled) return;
|
|
353
|
+
settled = true;
|
|
354
|
+
const idx = this.snapshotResolvers.indexOf(resolver);
|
|
355
|
+
if (idx !== -1) this.snapshotResolvers.splice(idx, 1);
|
|
356
|
+
reject(
|
|
357
|
+
new Error(
|
|
358
|
+
`SSE snapshot not received within ${_SSECollector.SNAPSHOT_TIMEOUT_MS}ms \u2014 is the dev server healthy?`
|
|
359
|
+
)
|
|
360
|
+
);
|
|
361
|
+
}, _SSECollector.SNAPSHOT_TIMEOUT_MS);
|
|
362
|
+
this.snapshotResolvers.push(resolver);
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Consume the ReadableStream body, parse SSE lines, and dispatch events.
|
|
367
|
+
*/
|
|
368
|
+
async _consumeStream(body) {
|
|
369
|
+
const decoder = new TextDecoder();
|
|
370
|
+
let buffer = "";
|
|
371
|
+
const reader = body.getReader();
|
|
372
|
+
try {
|
|
373
|
+
while (this.connected) {
|
|
374
|
+
const { done, value } = await reader.read();
|
|
375
|
+
if (done) break;
|
|
376
|
+
buffer += decoder.decode(value, { stream: true });
|
|
377
|
+
const parts = buffer.split("\n\n");
|
|
378
|
+
buffer = parts.pop() ?? "";
|
|
379
|
+
for (const part of parts) {
|
|
380
|
+
this._parseSSEBlock(part);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
} finally {
|
|
384
|
+
reader.releaseLock();
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Parse a single SSE event block (text between \n\n boundaries).
|
|
389
|
+
* Extracts event name and data payload.
|
|
390
|
+
*/
|
|
391
|
+
_parseSSEBlock(block) {
|
|
392
|
+
const lines = block.split("\n");
|
|
393
|
+
let eventName = "";
|
|
394
|
+
let dataLine = "";
|
|
395
|
+
for (const line of lines) {
|
|
396
|
+
if (line.startsWith("event:")) {
|
|
397
|
+
eventName = line.slice("event:".length).trim();
|
|
398
|
+
} else if (line.startsWith("data:")) {
|
|
399
|
+
const value = line.slice("data:".length).trim();
|
|
400
|
+
dataLine = dataLine ? `${dataLine}
|
|
401
|
+
${value}` : value;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (!eventName || !dataLine) {
|
|
405
|
+
if (!this.snapshotReceived && block.includes("retry:")) {
|
|
406
|
+
}
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
try {
|
|
410
|
+
const payload = JSON.parse(dataLine);
|
|
411
|
+
this._handleEvent(eventName, payload);
|
|
412
|
+
} catch {
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Dispatch a parsed SSE event to the appropriate handler.
|
|
417
|
+
*/
|
|
418
|
+
_handleEvent(eventName, payload) {
|
|
419
|
+
if (eventName === "snapshot") {
|
|
420
|
+
const data2 = payload;
|
|
421
|
+
const spans = Array.isArray(data2.spans) ? data2.spans : [];
|
|
422
|
+
for (const span of spans) {
|
|
423
|
+
this._upsertSpan(span);
|
|
424
|
+
}
|
|
425
|
+
this.snapshotReceived = true;
|
|
426
|
+
for (const resolve2 of this.snapshotResolvers) {
|
|
427
|
+
resolve2();
|
|
428
|
+
}
|
|
429
|
+
this.snapshotResolvers = [];
|
|
430
|
+
} else if (eventName === "update") {
|
|
431
|
+
const data2 = payload;
|
|
432
|
+
if (data2.span) {
|
|
433
|
+
this._upsertSpan(data2.span);
|
|
434
|
+
this._notifyWaiters(data2.span);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Insert or replace a span in the map (dedup by trace+span ID).
|
|
440
|
+
*/
|
|
441
|
+
_upsertSpan(span) {
|
|
442
|
+
this.spanMap.set(spanKey(span), span);
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Notify all waiters that a new span was received.
|
|
446
|
+
*/
|
|
447
|
+
_notifyWaiters(span) {
|
|
448
|
+
const current = [...this.waiters];
|
|
449
|
+
for (const waiter of current) {
|
|
450
|
+
waiter(span);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Remove a waiter callback from the list.
|
|
455
|
+
*/
|
|
456
|
+
_removeWaiter(fn) {
|
|
457
|
+
const idx = this.waiters.indexOf(fn);
|
|
458
|
+
if (idx !== -1) {
|
|
459
|
+
this.waiters.splice(idx, 1);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Check if there is a handler.conversation span that:
|
|
464
|
+
* 1. Was NOT seen before startTurn()
|
|
465
|
+
* 2. Has terminal status (ok or error)
|
|
466
|
+
*
|
|
467
|
+
* Marks the matched key as consumed so the same handler span cannot
|
|
468
|
+
* satisfy both the current turn's settle phase AND the next turn's
|
|
469
|
+
* waitForTurnComplete check (which would otherwise resolve instantly
|
|
470
|
+
* against stale data instead of waiting for the new turn's handler).
|
|
471
|
+
*/
|
|
472
|
+
_hasNewCompletedHandler() {
|
|
473
|
+
for (const [key, span] of this.spanMap) {
|
|
474
|
+
if (span.name === "handler.conversation" && !this.seenHandlerKeys.has(key) && (span.status === "ok" || span.status === "error")) {
|
|
475
|
+
this.seenHandlerKeys.add(key);
|
|
476
|
+
return true;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Check if a handler.workflow span matches the given condition.
|
|
483
|
+
*/
|
|
484
|
+
_matchesWorkflowCondition(name, signal) {
|
|
485
|
+
for (const span of this.spanMap.values()) {
|
|
486
|
+
if (span.name !== "handler.workflow") continue;
|
|
487
|
+
const d = spanData(span);
|
|
488
|
+
const wfName = d["workflow.name"] || d["workflowName"];
|
|
489
|
+
if (wfName !== name) continue;
|
|
490
|
+
if (signal === "entered") {
|
|
491
|
+
return true;
|
|
492
|
+
}
|
|
493
|
+
if (signal === "completed") {
|
|
494
|
+
return span.status === "ok" || span.status === "error";
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
// src/transformer.ts
|
|
502
|
+
function data(span) {
|
|
503
|
+
return span.data && typeof span.data === "object" ? span.data : {};
|
|
504
|
+
}
|
|
505
|
+
function tryParseJson(value) {
|
|
506
|
+
if (value == null) return void 0;
|
|
507
|
+
if (typeof value !== "string") return value;
|
|
508
|
+
try {
|
|
509
|
+
return JSON.parse(value);
|
|
510
|
+
} catch {
|
|
511
|
+
return value;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
function parseJsonOrFallback(value, fallback) {
|
|
515
|
+
if (typeof value !== "string") return fallback;
|
|
516
|
+
try {
|
|
517
|
+
return JSON.parse(value);
|
|
518
|
+
} catch {
|
|
519
|
+
return fallback;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
function transformSpans(spans) {
|
|
523
|
+
return {
|
|
524
|
+
messages: extractMessages(spans),
|
|
525
|
+
toolCalls: extractToolCalls(spans),
|
|
526
|
+
stateMutations: extractStateMutations(spans),
|
|
527
|
+
workflowSpans: extractWorkflowSpans(spans),
|
|
528
|
+
...extractHandler(spans)
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
function extractMessages(spans) {
|
|
532
|
+
return spans.filter((s) => s.name === "chat.sendMessage" && data(s)["message.preview"]).sort((a, b) => a.timing.startedAt - b.timing.startedAt).map((s) => String(data(s)["message.preview"]));
|
|
533
|
+
}
|
|
534
|
+
function extractToolCalls(spans) {
|
|
535
|
+
const toolSpans = spans.filter(
|
|
536
|
+
(s) => s.name === "autonomous.tool" && s.status !== "running" && data(s)["autonomous.tool.name"]
|
|
537
|
+
);
|
|
538
|
+
const seen = /* @__PURE__ */ new Set();
|
|
539
|
+
const unique = toolSpans.filter((s) => {
|
|
540
|
+
if (seen.has(s.id.span)) return false;
|
|
541
|
+
seen.add(s.id.span);
|
|
542
|
+
return true;
|
|
543
|
+
});
|
|
544
|
+
return unique.sort((a, b) => (a.timing.endedAt ?? Infinity) - (b.timing.endedAt ?? Infinity)).map((s) => {
|
|
545
|
+
const d = data(s);
|
|
546
|
+
const input = parseJsonOrFallback(d["autonomous.tool.input"], {});
|
|
547
|
+
return {
|
|
548
|
+
name: String(d["autonomous.tool.name"]),
|
|
549
|
+
input,
|
|
550
|
+
output: String(d["autonomous.tool.output"] ?? ""),
|
|
551
|
+
status: String(d["autonomous.tool.status"] ?? "unknown")
|
|
552
|
+
};
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
function extractStateMutations(spans) {
|
|
556
|
+
return spans.filter((s) => {
|
|
557
|
+
if (s.name !== "state.save") return false;
|
|
558
|
+
const d = data(s);
|
|
559
|
+
const changedKeys = d["state.changed_keys"];
|
|
560
|
+
return Array.isArray(changedKeys) && changedKeys.length > 0;
|
|
561
|
+
}).map((s) => {
|
|
562
|
+
const d = data(s);
|
|
563
|
+
return {
|
|
564
|
+
type: String(d["type"] ?? "unknown"),
|
|
565
|
+
changedKeys: d["state.changed_keys"] ?? [],
|
|
566
|
+
previous: tryParseJson(d["state.previous_value"]),
|
|
567
|
+
current: tryParseJson(d["state.value"]),
|
|
568
|
+
swappedToFile: d["swapped_to_file"] === true
|
|
569
|
+
};
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
function extractWorkflowSpans(spans) {
|
|
573
|
+
return spans.filter((s) => s.name === "handler.workflow").map((s) => {
|
|
574
|
+
const d = data(s);
|
|
575
|
+
const result = {
|
|
576
|
+
name: String(d["workflow.name"] || d["workflowName"] || ""),
|
|
577
|
+
status: s.status
|
|
578
|
+
};
|
|
579
|
+
const statusFinal = d["workflow.status.final"];
|
|
580
|
+
if (statusFinal) {
|
|
581
|
+
result.statusFinal = String(statusFinal);
|
|
582
|
+
}
|
|
583
|
+
return result;
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
function extractHandler(spans) {
|
|
587
|
+
const handler = spans.find(
|
|
588
|
+
(s) => (s.name === "handler.conversation" || s.name === "handler.workflow") && s.status !== "running"
|
|
589
|
+
);
|
|
590
|
+
if (!handler) {
|
|
591
|
+
return { handlerDuration: 0, handlerStatus: "ok" };
|
|
592
|
+
}
|
|
593
|
+
return {
|
|
594
|
+
handlerDuration: handler.timing.duration ?? 0,
|
|
595
|
+
handlerStatus: handler.status === "error" ? "error" : "ok"
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// src/graders/llm.ts
|
|
600
|
+
import { Client } from "@botpress/client";
|
|
601
|
+
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
|
+
}
|
|
627
|
+
var MODEL_ALIASES = ["fast", "best"];
|
|
628
|
+
var _cognitive = null;
|
|
629
|
+
var _availableModels = [];
|
|
630
|
+
var _judgeModel = "fast";
|
|
631
|
+
function _isModelValid(model) {
|
|
632
|
+
if (MODEL_ALIASES.includes(model)) return true;
|
|
633
|
+
if (_availableModels.length === 0) return false;
|
|
634
|
+
return _availableModels.includes(model);
|
|
635
|
+
}
|
|
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 });
|
|
645
|
+
try {
|
|
646
|
+
_availableModels = Array.from((await _cognitive.fetchRemoteModels()).keys());
|
|
647
|
+
} catch (err) {
|
|
648
|
+
log.warn(`Failed to fetch available models: ${err.message}. LLM judge will be unavailable.`);
|
|
649
|
+
_availableModels = [];
|
|
650
|
+
}
|
|
651
|
+
_judgeModel = options?.model ?? "fast";
|
|
652
|
+
if (_availableModels.length === 0) {
|
|
653
|
+
log.warn("No available Cognitive models: cannot validate judge model.");
|
|
654
|
+
} else if (!_isModelValid(_judgeModel)) {
|
|
655
|
+
log.warn(`Configured LLM judge model "${_judgeModel}" is invalid. Run "adk models" to list available models.`);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
async function gradeLLMJudge(botResponse, criteria, context) {
|
|
659
|
+
const threshold = Math.max(1, Math.min(5, context.passThreshold ?? DEFAULT_PASS_THRESHOLD));
|
|
660
|
+
try {
|
|
661
|
+
if (!_cognitive) {
|
|
662
|
+
return {
|
|
663
|
+
assertion: `llm_judge: "${criteria}"`,
|
|
664
|
+
pass: true,
|
|
665
|
+
expected: criteria,
|
|
666
|
+
actual: "SKIPPED \u2014 LLM judge unavailable: no credentials configured"
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
if (!_isModelValid(_judgeModel)) {
|
|
670
|
+
return {
|
|
671
|
+
assertion: `llm_judge: "${criteria}"`,
|
|
672
|
+
pass: false,
|
|
673
|
+
expected: criteria,
|
|
674
|
+
actual: "FAILED \u2014 invalid LLM judge model"
|
|
675
|
+
};
|
|
676
|
+
}
|
|
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);
|
|
704
|
+
return {
|
|
705
|
+
assertion: `llm_judge: "${criteria}"`,
|
|
706
|
+
pass: verdict.score >= threshold,
|
|
707
|
+
expected: criteria,
|
|
708
|
+
actual: `Score ${verdict.score}/5 (threshold: ${threshold}) \u2014 ${verdict.reason}`
|
|
709
|
+
};
|
|
710
|
+
} catch (err) {
|
|
711
|
+
return {
|
|
712
|
+
assertion: `llm_judge: "${criteria}"`,
|
|
713
|
+
pass: true,
|
|
714
|
+
expected: criteria,
|
|
715
|
+
actual: `SKIPPED \u2014 LLM judge unavailable: ${err.message}`
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// src/graders/response.ts
|
|
721
|
+
async function gradeResponse(botResponse, assertions, context) {
|
|
722
|
+
const results = [];
|
|
723
|
+
for (const assertion of assertions) {
|
|
724
|
+
if ("contains" in assertion) {
|
|
725
|
+
const pass = botResponse.toLowerCase().includes(assertion.contains.toLowerCase());
|
|
726
|
+
results.push({
|
|
727
|
+
assertion: `contains "${assertion.contains}"`,
|
|
728
|
+
pass,
|
|
729
|
+
expected: `Response contains "${assertion.contains}"`,
|
|
730
|
+
actual: pass ? `Found in response` : `Not found in response`
|
|
731
|
+
});
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
if ("not_contains" in assertion) {
|
|
735
|
+
const pass = !botResponse.toLowerCase().includes(assertion.not_contains.toLowerCase());
|
|
736
|
+
results.push({
|
|
737
|
+
assertion: `not_contains "${assertion.not_contains}"`,
|
|
738
|
+
pass,
|
|
739
|
+
expected: `Response does not contain "${assertion.not_contains}"`,
|
|
740
|
+
actual: pass ? `Not found in response` : `Found in response`
|
|
741
|
+
});
|
|
742
|
+
continue;
|
|
743
|
+
}
|
|
744
|
+
if ("matches" in assertion) {
|
|
745
|
+
const regex = new RegExp(assertion.matches, "i");
|
|
746
|
+
const pass = regex.test(botResponse);
|
|
747
|
+
results.push({
|
|
748
|
+
assertion: `matches ${assertion.matches}`,
|
|
749
|
+
pass,
|
|
750
|
+
expected: `Response matches /${assertion.matches}/`,
|
|
751
|
+
actual: pass ? `Matched` : `No match`
|
|
752
|
+
});
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
if ("llm_judge" in assertion) {
|
|
756
|
+
const result = await gradeLLMJudge(botResponse, assertion.llm_judge, {
|
|
757
|
+
userMessage: context.userMessage,
|
|
758
|
+
...context.judgePassThreshold !== void 0 ? { passThreshold: context.judgePassThreshold } : {}
|
|
759
|
+
});
|
|
760
|
+
results.push(result);
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
results.push({
|
|
764
|
+
assertion: "unknown",
|
|
765
|
+
pass: false,
|
|
766
|
+
expected: "known assertion type",
|
|
767
|
+
actual: `Unknown assertion: ${JSON.stringify(assertion)}`
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
return results;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// src/graders/match.ts
|
|
774
|
+
function matchValue(operator, actual) {
|
|
775
|
+
if (typeof operator === "string") {
|
|
776
|
+
return String(actual) === operator;
|
|
777
|
+
}
|
|
778
|
+
if ("equals" in operator) {
|
|
779
|
+
return actual === operator.equals;
|
|
780
|
+
}
|
|
781
|
+
if ("contains" in operator) {
|
|
782
|
+
return String(actual).toLowerCase().includes(operator.contains.toLowerCase());
|
|
783
|
+
}
|
|
784
|
+
if ("not_contains" in operator) {
|
|
785
|
+
return !String(actual).toLowerCase().includes(operator.not_contains.toLowerCase());
|
|
786
|
+
}
|
|
787
|
+
if ("matches" in operator) {
|
|
788
|
+
return new RegExp(operator.matches, "i").test(String(actual));
|
|
789
|
+
}
|
|
790
|
+
if ("in" in operator) {
|
|
791
|
+
return operator.in.includes(actual);
|
|
792
|
+
}
|
|
793
|
+
if ("exists" in operator) {
|
|
794
|
+
return operator.exists ? actual !== void 0 && actual !== null : actual === void 0 || actual === null;
|
|
795
|
+
}
|
|
796
|
+
if ("gte" in operator) {
|
|
797
|
+
return Number(actual) >= operator.gte;
|
|
798
|
+
}
|
|
799
|
+
if ("lte" in operator) {
|
|
800
|
+
return Number(actual) <= operator.lte;
|
|
801
|
+
}
|
|
802
|
+
return false;
|
|
803
|
+
}
|
|
804
|
+
function operatorToString(operator) {
|
|
805
|
+
if (typeof operator === "string") return `equals "${operator}"`;
|
|
806
|
+
if ("equals" in operator) return `equals ${JSON.stringify(operator.equals)}`;
|
|
807
|
+
if ("contains" in operator) return `contains "${operator.contains}"`;
|
|
808
|
+
if ("not_contains" in operator) return `not_contains "${operator.not_contains}"`;
|
|
809
|
+
if ("matches" in operator) return `matches /${operator.matches}/`;
|
|
810
|
+
if ("in" in operator) return `in [${operator.in.map((v) => JSON.stringify(v)).join(", ")}]`;
|
|
811
|
+
if ("exists" in operator) return operator.exists ? "exists" : "does not exist";
|
|
812
|
+
if ("gte" in operator) return `>= ${operator.gte}`;
|
|
813
|
+
if ("lte" in operator) return `<= ${operator.lte}`;
|
|
814
|
+
return JSON.stringify(operator);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// src/graders/tools.ts
|
|
818
|
+
function gradeTools(toolCalls, assertions) {
|
|
819
|
+
return assertions.map((assertion) => {
|
|
820
|
+
if ("called" in assertion && !("not_called" in assertion) && !("call_order" in assertion)) {
|
|
821
|
+
const matches = toolCalls.filter((tc) => tc.name === assertion.called);
|
|
822
|
+
const wasCalled = matches.length > 0;
|
|
823
|
+
if (!wasCalled) {
|
|
824
|
+
return {
|
|
825
|
+
assertion: `tool called: ${assertion.called}`,
|
|
826
|
+
pass: false,
|
|
827
|
+
expected: `${assertion.called} was called`,
|
|
828
|
+
actual: `Not called. Tools called: [${toolCalls.map((tc) => tc.name).join(", ") || "none"}]`
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
if (assertion.params) {
|
|
832
|
+
const paramResults = [];
|
|
833
|
+
for (const [key, operator] of Object.entries(assertion.params)) {
|
|
834
|
+
const anyMatch = matches.some((tc) => matchValue(operator, tc.input[key]));
|
|
835
|
+
paramResults.push({
|
|
836
|
+
key,
|
|
837
|
+
pass: anyMatch,
|
|
838
|
+
detail: anyMatch ? `matched` : `expected ${key} ${operatorToString(operator)}, got ${JSON.stringify(matches.map((tc) => tc.input[key]))}`
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
const allParamsPass = paramResults.every((p) => p.pass);
|
|
842
|
+
const failedParams = paramResults.filter((p) => !p.pass);
|
|
843
|
+
return {
|
|
844
|
+
assertion: `tool called: ${assertion.called} with params`,
|
|
845
|
+
pass: allParamsPass,
|
|
846
|
+
expected: `${assertion.called} called with ${Object.entries(assertion.params).map(([k, v]) => `${k} ${operatorToString(v)}`).join(", ")}`,
|
|
847
|
+
actual: allParamsPass ? `Matched` : failedParams.map((p) => p.detail).join("; ")
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
return {
|
|
851
|
+
assertion: `tool called: ${assertion.called}`,
|
|
852
|
+
pass: true,
|
|
853
|
+
expected: `${assertion.called} was called`,
|
|
854
|
+
actual: `Called ${matches.length} time(s)`
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
if ("not_called" in assertion) {
|
|
858
|
+
const wasCalled = toolCalls.some((tc) => tc.name === assertion.not_called);
|
|
859
|
+
return {
|
|
860
|
+
assertion: `tool not_called: ${assertion.not_called}`,
|
|
861
|
+
pass: !wasCalled,
|
|
862
|
+
expected: `${assertion.not_called} was NOT called`,
|
|
863
|
+
actual: wasCalled ? `Was called` : `Not called`
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
if ("call_order" in assertion) {
|
|
867
|
+
const calledNames = toolCalls.map((tc) => tc.name);
|
|
868
|
+
const expectedOrder = assertion.call_order;
|
|
869
|
+
let cursor = 0;
|
|
870
|
+
for (const name of calledNames) {
|
|
871
|
+
if (cursor < expectedOrder.length && name === expectedOrder[cursor]) {
|
|
872
|
+
cursor++;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
const inOrder = cursor === expectedOrder.length;
|
|
876
|
+
return {
|
|
877
|
+
assertion: `call_order: [${expectedOrder.join(" \u2192 ")}]`,
|
|
878
|
+
pass: inOrder,
|
|
879
|
+
expected: `Tools called in order: [${expectedOrder.join(" \u2192 ")}]`,
|
|
880
|
+
actual: `Actual order: [${calledNames.join(" \u2192 ") || "none"}]`
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
return {
|
|
884
|
+
assertion: "unknown tool assertion",
|
|
885
|
+
pass: false,
|
|
886
|
+
expected: "known assertion type",
|
|
887
|
+
actual: `Unknown: ${JSON.stringify(assertion)}`
|
|
888
|
+
};
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// src/graders/state.ts
|
|
893
|
+
function parseStatePath(path) {
|
|
894
|
+
const dot = path.indexOf(".");
|
|
895
|
+
if (dot === -1) {
|
|
896
|
+
throw new Error(`Invalid state path "${path}" \u2014 expected "type.field" format`);
|
|
897
|
+
}
|
|
898
|
+
const type = path.slice(0, dot);
|
|
899
|
+
const field = path.slice(dot + 1);
|
|
900
|
+
if (type !== "bot" && type !== "user" && type !== "conversation") {
|
|
901
|
+
throw new Error(`Unknown state type "${type}" in path "${path}" \u2014 expected bot, user, or conversation`);
|
|
902
|
+
}
|
|
903
|
+
return { type, field };
|
|
904
|
+
}
|
|
905
|
+
function gradeState(mutations, assertions) {
|
|
906
|
+
const results = [];
|
|
907
|
+
for (const assertion of assertions) {
|
|
908
|
+
const parsed = parseStatePath(assertion.path);
|
|
909
|
+
const matchingMutations = mutations.filter((m) => m.type === parsed.type);
|
|
910
|
+
if (assertion.equals !== void 0) {
|
|
911
|
+
if (matchingMutations.length === 0) {
|
|
912
|
+
results.push({
|
|
913
|
+
assertion: `state: ${assertion.path} equals`,
|
|
914
|
+
pass: false,
|
|
915
|
+
expected: JSON.stringify(assertion.equals),
|
|
916
|
+
actual: `No state.save span found for "${parsed.type}" \u2014 the bot did not mutate this state during the turn. If this state was seeded via setup.state, assert on the tool/action that reads it, or use a conversation turn that triggers a state write.`
|
|
917
|
+
});
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
const lastMutation = matchingMutations[matchingMutations.length - 1];
|
|
921
|
+
if (lastMutation.swappedToFile) {
|
|
922
|
+
results.push({
|
|
923
|
+
assertion: `state: ${assertion.path} equals`,
|
|
924
|
+
pass: false,
|
|
925
|
+
expected: JSON.stringify(assertion.equals),
|
|
926
|
+
actual: "State was too large for trace-based assertion (swapped to file)"
|
|
927
|
+
});
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
const currentState = lastMutation.current;
|
|
931
|
+
const actualValue = currentState ? currentState[parsed.field] : void 0;
|
|
932
|
+
const pass = deepEqual(actualValue, assertion.equals);
|
|
933
|
+
results.push({
|
|
934
|
+
assertion: `state: ${assertion.path} equals`,
|
|
935
|
+
pass,
|
|
936
|
+
expected: JSON.stringify(assertion.equals),
|
|
937
|
+
actual: JSON.stringify(actualValue)
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
if (assertion.changed !== void 0) {
|
|
941
|
+
if (matchingMutations.length === 0) {
|
|
942
|
+
results.push({
|
|
943
|
+
assertion: `state: ${assertion.path} ${assertion.changed ? "changed" : "unchanged"}`,
|
|
944
|
+
pass: !assertion.changed,
|
|
945
|
+
expected: assertion.changed ? `Field "${parsed.field}" changed in a "${parsed.type}" state mutation` : `Field "${parsed.field}" unchanged`,
|
|
946
|
+
actual: 'No state mutation found for type "' + parsed.type + '"'
|
|
947
|
+
});
|
|
948
|
+
continue;
|
|
949
|
+
}
|
|
950
|
+
const hasSwapped = matchingMutations.some((m) => m.swappedToFile);
|
|
951
|
+
if (hasSwapped) {
|
|
952
|
+
results.push({
|
|
953
|
+
assertion: `state: ${assertion.path} ${assertion.changed ? "changed" : "unchanged"}`,
|
|
954
|
+
pass: false,
|
|
955
|
+
expected: assertion.changed ? `Field "${parsed.field}" changed in a "${parsed.type}" state mutation` : `Field "${parsed.field}" unchanged`,
|
|
956
|
+
actual: "State was too large for trace-based assertion (swapped to file)"
|
|
957
|
+
});
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
const fieldChanged = matchingMutations.some((m) => m.changedKeys.includes(parsed.field));
|
|
961
|
+
const pass = assertion.changed ? fieldChanged : !fieldChanged;
|
|
962
|
+
results.push({
|
|
963
|
+
assertion: `state: ${assertion.path} ${assertion.changed ? "changed" : "unchanged"}`,
|
|
964
|
+
pass,
|
|
965
|
+
expected: assertion.changed ? `Field "${parsed.field}" changed in a "${parsed.type}" state mutation` : `Field "${parsed.field}" unchanged`,
|
|
966
|
+
actual: fieldChanged ? `Field "${parsed.field}" was changed` : `Field "${parsed.field}" was not changed`
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
return results;
|
|
971
|
+
}
|
|
972
|
+
function deepEqual(a, b) {
|
|
973
|
+
if (a === b) return true;
|
|
974
|
+
if (a === null || b === null) return false;
|
|
975
|
+
if (typeof a !== typeof b) return false;
|
|
976
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
977
|
+
if (a.length !== b.length) return false;
|
|
978
|
+
return a.every((v, i) => deepEqual(v, b[i]));
|
|
979
|
+
}
|
|
980
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
981
|
+
if (typeof a === "object" && typeof b === "object") {
|
|
982
|
+
const keysA = Object.keys(a);
|
|
983
|
+
const keysB = Object.keys(b);
|
|
984
|
+
if (keysA.length !== keysB.length) return false;
|
|
985
|
+
return keysA.every((k) => deepEqual(a[k], b[k]));
|
|
986
|
+
}
|
|
987
|
+
return false;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// src/graders/workflow.ts
|
|
991
|
+
function gradeWorkflows(workflows, assertions) {
|
|
992
|
+
const results = [];
|
|
993
|
+
for (const assertion of assertions) {
|
|
994
|
+
const matching = workflows.filter((wf) => wf.name === assertion.name);
|
|
995
|
+
if (assertion.entered !== void 0) {
|
|
996
|
+
const wasEntered = matching.length > 0;
|
|
997
|
+
const pass = assertion.entered ? wasEntered : !wasEntered;
|
|
998
|
+
results.push({
|
|
999
|
+
assertion: `workflow: ${assertion.name} ${assertion.entered ? "entered" : "not entered"}`,
|
|
1000
|
+
pass,
|
|
1001
|
+
expected: assertion.entered ? `Workflow "${assertion.name}" was entered` : `Workflow "${assertion.name}" was not entered`,
|
|
1002
|
+
actual: wasEntered ? `Found ${matching.length} workflow span(s)` : `No workflow spans found`
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
if (assertion.completed !== void 0) {
|
|
1006
|
+
const didComplete = matching.some(
|
|
1007
|
+
(wf) => wf.statusFinal === "completed" || wf.status === "ok" || wf.status === "error"
|
|
1008
|
+
);
|
|
1009
|
+
const pass = assertion.completed ? didComplete : !didComplete;
|
|
1010
|
+
results.push({
|
|
1011
|
+
assertion: `workflow: ${assertion.name} ${assertion.completed ? "completed" : "not completed"}`,
|
|
1012
|
+
pass,
|
|
1013
|
+
expected: assertion.completed ? `Workflow "${assertion.name}" completed` : `Workflow "${assertion.name}" did not complete`,
|
|
1014
|
+
actual: didComplete ? `Completed` : `Not completed`
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
return results;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// src/graders/timing.ts
|
|
1022
|
+
function gradeTiming(botDuration, assertions) {
|
|
1023
|
+
const results = [];
|
|
1024
|
+
for (const assertion of assertions) {
|
|
1025
|
+
const pass = matchValue(assertion.response_time, botDuration);
|
|
1026
|
+
const expected = operatorToString(assertion.response_time);
|
|
1027
|
+
results.push({
|
|
1028
|
+
assertion: `response_time ${expected}`,
|
|
1029
|
+
pass,
|
|
1030
|
+
expected: `Response time ${expected}`,
|
|
1031
|
+
actual: `${botDuration}ms`
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
return results;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// src/graders/outcome.ts
|
|
1038
|
+
function gradeOutcome(mutations, evalDef, workflowSpans) {
|
|
1039
|
+
const outcome = evalDef.outcome;
|
|
1040
|
+
if (!outcome) return [];
|
|
1041
|
+
const results = [];
|
|
1042
|
+
if (outcome.state && outcome.state.length > 0) {
|
|
1043
|
+
const stateResults = gradeState(mutations, outcome.state);
|
|
1044
|
+
results.push(...stateResults);
|
|
1045
|
+
}
|
|
1046
|
+
if (outcome.workflow && outcome.workflow.length > 0) {
|
|
1047
|
+
const workflowResults = gradeWorkflows(workflowSpans, outcome.workflow);
|
|
1048
|
+
results.push(...workflowResults);
|
|
1049
|
+
}
|
|
1050
|
+
return results;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// src/loader.ts
|
|
1054
|
+
import { readdirSync, existsSync } from "fs";
|
|
1055
|
+
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
|
+
function isEvalDefinition(value) {
|
|
1081
|
+
return value !== null && typeof value === "object" && typeof value.name === "string" && value.name !== "" && Array.isArray(value.conversation);
|
|
1082
|
+
}
|
|
1083
|
+
async function loadEvalFile(filePath) {
|
|
1084
|
+
const absPath = resolve(filePath);
|
|
1085
|
+
let mod;
|
|
1086
|
+
try {
|
|
1087
|
+
mod = await import(absPath);
|
|
1088
|
+
} catch (err) {
|
|
1089
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1090
|
+
throw new Error(
|
|
1091
|
+
`Failed to load eval file ${filePath}: ${msg}
|
|
1092
|
+
|
|
1093
|
+
Make sure your eval file:
|
|
1094
|
+
- Has no syntax or type errors
|
|
1095
|
+
- Exports one or more \`new Eval({...})\` instances
|
|
1096
|
+
- Has all dependencies installed (\`bun install\`)`,
|
|
1097
|
+
{ cause: err }
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
const results = [];
|
|
1101
|
+
for (const [key, value] of Object.entries(mod)) {
|
|
1102
|
+
if (key === "__esModule") continue;
|
|
1103
|
+
if (value instanceof Eval || isEvalDefinition(value)) {
|
|
1104
|
+
results.push(value);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
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
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
return results;
|
|
1113
|
+
}
|
|
1114
|
+
async function loadEvalsFromDir(dirPath) {
|
|
1115
|
+
const absDir = resolve(dirPath);
|
|
1116
|
+
if (!existsSync(absDir)) {
|
|
1117
|
+
return [];
|
|
1118
|
+
}
|
|
1119
|
+
const files = readdirSync(absDir).filter((f) => f.endsWith(".eval.ts"));
|
|
1120
|
+
const evals = [];
|
|
1121
|
+
for (const f of files) {
|
|
1122
|
+
const defs = await loadEvalFile(`${absDir}/${f}`);
|
|
1123
|
+
evals.push(...defs);
|
|
1124
|
+
}
|
|
1125
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1126
|
+
for (const e of evals) {
|
|
1127
|
+
if (seen.has(e.name)) {
|
|
1128
|
+
throw new Error(`Duplicate eval name "${e.name}" found in ${dirPath}`);
|
|
1129
|
+
}
|
|
1130
|
+
seen.add(e.name);
|
|
1131
|
+
}
|
|
1132
|
+
return evals;
|
|
1133
|
+
}
|
|
1134
|
+
function filterEvals(evals, filter) {
|
|
1135
|
+
if (!filter) return evals;
|
|
1136
|
+
return evals.filter((e) => {
|
|
1137
|
+
if (filter.names && filter.names.length > 0) {
|
|
1138
|
+
if (!filter.names.includes(e.name)) return false;
|
|
1139
|
+
}
|
|
1140
|
+
if (filter.tags && filter.tags.length > 0) {
|
|
1141
|
+
if (!e.tags || !filter.tags.some((t) => e.tags.includes(t))) return false;
|
|
1142
|
+
}
|
|
1143
|
+
if (filter.type) {
|
|
1144
|
+
if (e.type !== filter.type) return false;
|
|
1145
|
+
}
|
|
1146
|
+
return true;
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// src/runner.ts
|
|
1151
|
+
import { randomUUID } from "crypto";
|
|
1152
|
+
var DEFAULT_IDLE_TIMEOUT = 3e4;
|
|
1153
|
+
var DEFAULT_DEV_SERVER_URL = "http://localhost:3001";
|
|
1154
|
+
var WORKFLOW_TRIGGER_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
1155
|
+
var BUILT_IN_STATES = {
|
|
1156
|
+
conversation: "state",
|
|
1157
|
+
user: "userState",
|
|
1158
|
+
bot: "botState"
|
|
1159
|
+
};
|
|
1160
|
+
function buildStatePayload(value) {
|
|
1161
|
+
return {
|
|
1162
|
+
value,
|
|
1163
|
+
location: { type: "state" }
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
async function seedEvalState(client, ctx, setup) {
|
|
1167
|
+
const state = setup?.state;
|
|
1168
|
+
if (!state) return;
|
|
1169
|
+
const writes = [];
|
|
1170
|
+
if (state.bot) {
|
|
1171
|
+
writes.push(
|
|
1172
|
+
client.setState({
|
|
1173
|
+
type: "bot",
|
|
1174
|
+
id: ctx.botId,
|
|
1175
|
+
name: BUILT_IN_STATES.bot,
|
|
1176
|
+
payload: buildStatePayload(state.bot)
|
|
1177
|
+
})
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
if (state.user) {
|
|
1181
|
+
writes.push(
|
|
1182
|
+
client.setState({
|
|
1183
|
+
type: "user",
|
|
1184
|
+
id: ctx.userId,
|
|
1185
|
+
name: BUILT_IN_STATES.user,
|
|
1186
|
+
payload: buildStatePayload(state.user)
|
|
1187
|
+
})
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
1190
|
+
if (state.conversation) {
|
|
1191
|
+
if (!ctx.conversationId) {
|
|
1192
|
+
throw new Error("Cannot seed conversation state before a conversation is created.");
|
|
1193
|
+
}
|
|
1194
|
+
writes.push(
|
|
1195
|
+
client.setState({
|
|
1196
|
+
type: "conversation",
|
|
1197
|
+
id: ctx.conversationId,
|
|
1198
|
+
name: BUILT_IN_STATES.conversation,
|
|
1199
|
+
payload: buildStatePayload(state.conversation)
|
|
1200
|
+
})
|
|
1201
|
+
);
|
|
1202
|
+
}
|
|
1203
|
+
await Promise.all(writes);
|
|
1204
|
+
}
|
|
1205
|
+
async function triggerEvalWorkflow(client, ctx, setup) {
|
|
1206
|
+
const workflow = setup?.workflow;
|
|
1207
|
+
if (!workflow) return;
|
|
1208
|
+
await client.createWorkflow({
|
|
1209
|
+
name: workflow.trigger,
|
|
1210
|
+
input: workflow.input ?? {},
|
|
1211
|
+
status: "pending",
|
|
1212
|
+
userId: ctx.userId,
|
|
1213
|
+
timeoutAt: new Date(Date.now() + WORKFLOW_TRIGGER_TIMEOUT_MS).toISOString(),
|
|
1214
|
+
...ctx.conversationId !== void 0 ? { conversationId: ctx.conversationId } : {}
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
function evalMetadata(evalDef) {
|
|
1218
|
+
return {
|
|
1219
|
+
name: evalDef.name,
|
|
1220
|
+
...evalDef.description !== void 0 ? { description: evalDef.description } : {},
|
|
1221
|
+
...evalDef.type !== void 0 ? { type: evalDef.type } : {},
|
|
1222
|
+
...evalDef.tags !== void 0 ? { tags: evalDef.tags } : {}
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
async function runEval(evalDef, connection, options = {}) {
|
|
1226
|
+
const devServerUrl = options.devServerUrl || DEFAULT_DEV_SERVER_URL;
|
|
1227
|
+
const devServerHeaders = options.devServerHeaders ?? {};
|
|
1228
|
+
const idleTimeout = evalDef.options?.idleTimeout ?? options.idleTimeout ?? DEFAULT_IDLE_TIMEOUT;
|
|
1229
|
+
const judgePassThreshold = evalDef.options?.judgePassThreshold ?? options.judgePassThreshold;
|
|
1230
|
+
const start = Date.now();
|
|
1231
|
+
const turns = [];
|
|
1232
|
+
let outcomeAssertions = [];
|
|
1233
|
+
const collector = new SSECollector(devServerUrl, devServerHeaders);
|
|
1234
|
+
try {
|
|
1235
|
+
const session = new ChatSession(connection.webhookId, options.chatClient);
|
|
1236
|
+
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
|
+
let conversationId = "";
|
|
1249
|
+
if (evalDef.setup?.state?.conversation || evalDef.setup?.workflow) {
|
|
1250
|
+
conversationId = await session.ensureConversation();
|
|
1251
|
+
}
|
|
1252
|
+
await seedEvalState(
|
|
1253
|
+
getBpClient(),
|
|
1254
|
+
{
|
|
1255
|
+
botId: connection.botId,
|
|
1256
|
+
userId: session.userId,
|
|
1257
|
+
...conversationId ? { conversationId } : {}
|
|
1258
|
+
},
|
|
1259
|
+
evalDef.setup
|
|
1260
|
+
);
|
|
1261
|
+
await triggerEvalWorkflow(
|
|
1262
|
+
getBpClient(),
|
|
1263
|
+
{
|
|
1264
|
+
userId: session.userId,
|
|
1265
|
+
...conversationId ? { conversationId } : {}
|
|
1266
|
+
},
|
|
1267
|
+
evalDef.setup
|
|
1268
|
+
);
|
|
1269
|
+
if (!conversationId && evalDef.conversation.length > 0) {
|
|
1270
|
+
conversationId = await session.ensureConversation();
|
|
1271
|
+
}
|
|
1272
|
+
if (conversationId) {
|
|
1273
|
+
await collector.connect({ conversationId });
|
|
1274
|
+
} 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.");
|
|
1276
|
+
}
|
|
1277
|
+
for (let i = 0; i < evalDef.conversation.length; i++) {
|
|
1278
|
+
if (options.signal?.aborted) break;
|
|
1279
|
+
const turn = evalDef.conversation[i];
|
|
1280
|
+
if (turn.expectSilence && turn.assert?.response) {
|
|
1281
|
+
throw new Error(`Turn ${i + 1}: 'expectSilence' and 'assert.response' are mutually exclusive.`);
|
|
1282
|
+
}
|
|
1283
|
+
if (turn.user && turn.event) {
|
|
1284
|
+
throw new Error(`Turn ${i + 1}: 'user' and 'event' are mutually exclusive.`);
|
|
1285
|
+
}
|
|
1286
|
+
if (!turn.user && !turn.event) {
|
|
1287
|
+
throw new Error(`Turn ${i + 1}: must have either 'user' or 'event'.`);
|
|
1288
|
+
}
|
|
1289
|
+
const turnLabelForEvent = turn.event ? `[event: ${turn.event.type}]` : turn.user;
|
|
1290
|
+
options.onProgress?.({
|
|
1291
|
+
type: "turn_start",
|
|
1292
|
+
evalName: evalDef.name,
|
|
1293
|
+
evalIndex: options.evalIndex ?? 0,
|
|
1294
|
+
turnNumber: i + 1,
|
|
1295
|
+
totalTurns: evalDef.conversation.length,
|
|
1296
|
+
userMessage: turnLabelForEvent
|
|
1297
|
+
});
|
|
1298
|
+
collector.startTurn();
|
|
1299
|
+
if (turn.event) {
|
|
1300
|
+
await session.sendEvent(turn.event.type, turn.event.payload ?? {});
|
|
1301
|
+
} else {
|
|
1302
|
+
await session.sendMessage(turn.user);
|
|
1303
|
+
}
|
|
1304
|
+
try {
|
|
1305
|
+
await collector.waitForTurnComplete({
|
|
1306
|
+
timeout: idleTimeout,
|
|
1307
|
+
...options.signal !== void 0 ? { abortSignal: options.signal } : {}
|
|
1308
|
+
});
|
|
1309
|
+
} catch (err) {
|
|
1310
|
+
if (options.signal?.aborted) break;
|
|
1311
|
+
throw err;
|
|
1312
|
+
}
|
|
1313
|
+
if (turn.assert?.workflow) {
|
|
1314
|
+
for (const wfAssert of turn.assert.workflow) {
|
|
1315
|
+
const expectsPositive = wfAssert.entered !== false && wfAssert.completed !== false;
|
|
1316
|
+
if (!expectsPositive) continue;
|
|
1317
|
+
const wfSignal = wfAssert.completed ? "completed" : "entered";
|
|
1318
|
+
await collector.waitForWorkflow(wfAssert.name, {
|
|
1319
|
+
signal: wfSignal,
|
|
1320
|
+
timeout: idleTimeout,
|
|
1321
|
+
...options.signal !== void 0 ? { abortSignal: options.signal } : {}
|
|
1322
|
+
}).catch(() => {
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
const turnSpans = collector.getTurnSpans();
|
|
1327
|
+
const turnData = transformSpans(turnSpans);
|
|
1328
|
+
const botResponse = turnData.messages.join("\n");
|
|
1329
|
+
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
|
+
const evalStart = Date.now();
|
|
1335
|
+
let assertions = [];
|
|
1336
|
+
if (turn.expectSilence) {
|
|
1337
|
+
const wasSilent = turnData.messages.length === 0;
|
|
1338
|
+
assertions.push({
|
|
1339
|
+
assertion: "no_response",
|
|
1340
|
+
pass: wasSilent,
|
|
1341
|
+
expected: "No response",
|
|
1342
|
+
actual: wasSilent ? "No response" : `Bot responded: "${botResponse}"`
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
if (turn.assert?.response) {
|
|
1346
|
+
assertions = await gradeResponse(botResponse, turn.assert.response, {
|
|
1347
|
+
userMessage: turnLabel,
|
|
1348
|
+
...judgePassThreshold !== void 0 ? { judgePassThreshold } : {}
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
if (turn.assert?.tools) {
|
|
1352
|
+
const toolResults = gradeTools(turnData.toolCalls, turn.assert.tools);
|
|
1353
|
+
assertions = [...assertions, ...toolResults];
|
|
1354
|
+
}
|
|
1355
|
+
if (turn.assert?.state) {
|
|
1356
|
+
try {
|
|
1357
|
+
const stateResults = gradeState(turnData.stateMutations, turn.assert.state);
|
|
1358
|
+
assertions.push(...stateResults);
|
|
1359
|
+
} catch (err) {
|
|
1360
|
+
assertions.push({
|
|
1361
|
+
assertion: "state",
|
|
1362
|
+
pass: false,
|
|
1363
|
+
expected: "State assertions executed",
|
|
1364
|
+
actual: `Error: ${err.message}`
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
if (turn.assert?.workflow) {
|
|
1369
|
+
const workflowResults = gradeWorkflows(turnData.workflowSpans, turn.assert.workflow);
|
|
1370
|
+
assertions.push(...workflowResults);
|
|
1371
|
+
}
|
|
1372
|
+
if (turn.assert?.timing) {
|
|
1373
|
+
const timingResults = gradeTiming(botDuration, turn.assert.timing);
|
|
1374
|
+
assertions.push(...timingResults);
|
|
1375
|
+
}
|
|
1376
|
+
const turnPass = assertions.every((a) => a.pass);
|
|
1377
|
+
const evalDuration = Date.now() - evalStart;
|
|
1378
|
+
const turnReport = {
|
|
1379
|
+
turnNumber: i + 1,
|
|
1380
|
+
userMessage: turnLabel,
|
|
1381
|
+
botResponse,
|
|
1382
|
+
assertions,
|
|
1383
|
+
pass: turnPass,
|
|
1384
|
+
botDuration,
|
|
1385
|
+
evalDuration
|
|
1386
|
+
};
|
|
1387
|
+
turns.push(turnReport);
|
|
1388
|
+
options.onProgress?.({
|
|
1389
|
+
type: "turn_complete",
|
|
1390
|
+
evalName: evalDef.name,
|
|
1391
|
+
evalIndex: options.evalIndex ?? 0,
|
|
1392
|
+
turnNumber: i + 1,
|
|
1393
|
+
totalTurns: evalDef.conversation.length,
|
|
1394
|
+
turnReport
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
if (evalDef.outcome && !options.signal?.aborted) {
|
|
1398
|
+
if (evalDef.outcome.workflow) {
|
|
1399
|
+
for (const wfAssert of evalDef.outcome.workflow) {
|
|
1400
|
+
const expectsPositive = wfAssert.entered !== false && wfAssert.completed !== false;
|
|
1401
|
+
if (!expectsPositive) continue;
|
|
1402
|
+
const wfSignal = wfAssert.completed ? "completed" : "entered";
|
|
1403
|
+
await collector.waitForWorkflow(wfAssert.name, {
|
|
1404
|
+
signal: wfSignal,
|
|
1405
|
+
timeout: idleTimeout,
|
|
1406
|
+
...options.signal !== void 0 ? { abortSignal: options.signal } : {}
|
|
1407
|
+
}).catch(() => {
|
|
1408
|
+
});
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
try {
|
|
1412
|
+
const allTurnData = transformSpans(collector.getAllSpans());
|
|
1413
|
+
outcomeAssertions = gradeOutcome(allTurnData.stateMutations, evalDef, allTurnData.workflowSpans);
|
|
1414
|
+
} catch (err) {
|
|
1415
|
+
outcomeAssertions = [
|
|
1416
|
+
{
|
|
1417
|
+
assertion: "outcome",
|
|
1418
|
+
pass: false,
|
|
1419
|
+
expected: "Outcome assertions executed",
|
|
1420
|
+
actual: `Error: ${err.message}`
|
|
1421
|
+
}
|
|
1422
|
+
];
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
const aborted = options.signal?.aborted === true;
|
|
1426
|
+
const turnsPass = turns.every((t) => t.pass);
|
|
1427
|
+
const outcomePass = outcomeAssertions.every((a) => a.pass);
|
|
1428
|
+
return {
|
|
1429
|
+
...evalMetadata(evalDef),
|
|
1430
|
+
turns,
|
|
1431
|
+
outcomeAssertions,
|
|
1432
|
+
pass: !aborted && turnsPass && outcomePass,
|
|
1433
|
+
duration: Date.now() - start,
|
|
1434
|
+
...aborted ? { error: "Eval aborted" } : {}
|
|
1435
|
+
};
|
|
1436
|
+
} catch (err) {
|
|
1437
|
+
return {
|
|
1438
|
+
...evalMetadata(evalDef),
|
|
1439
|
+
turns,
|
|
1440
|
+
outcomeAssertions,
|
|
1441
|
+
pass: false,
|
|
1442
|
+
duration: Date.now() - start,
|
|
1443
|
+
error: err.message
|
|
1444
|
+
};
|
|
1445
|
+
} finally {
|
|
1446
|
+
collector.disconnect();
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
async function runEvalSuite(config, filter) {
|
|
1450
|
+
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);
|
|
1454
|
+
const evals = filterEvals(allEvals, filter);
|
|
1455
|
+
if (evals.length === 0) {
|
|
1456
|
+
return {
|
|
1457
|
+
id: runId,
|
|
1458
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1459
|
+
evals: [],
|
|
1460
|
+
passed: 0,
|
|
1461
|
+
failed: 0,
|
|
1462
|
+
total: 0,
|
|
1463
|
+
duration: 0,
|
|
1464
|
+
...filter !== void 0 ? { filter } : {}
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
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
|
+
}
|
|
1483
|
+
const connection = {
|
|
1484
|
+
webhookId,
|
|
1485
|
+
botId: config.credentials.botId,
|
|
1486
|
+
token: config.credentials.token,
|
|
1487
|
+
apiUrl: config.credentials.apiUrl
|
|
1488
|
+
};
|
|
1489
|
+
const devServerUrl = config.devServerUrl || DEFAULT_DEV_SERVER_URL;
|
|
1490
|
+
const devServerHeaders = config.devServerHeaders ?? {};
|
|
1491
|
+
await assertChatChannelBound(devServerUrl, devServerHeaders);
|
|
1492
|
+
const reports = [];
|
|
1493
|
+
config.onProgress?.({ type: "suite_start", totalEvals: evals.length });
|
|
1494
|
+
for (let i = 0; i < evals.length; i++) {
|
|
1495
|
+
if (config.signal?.aborted) break;
|
|
1496
|
+
const evalDef = evals[i];
|
|
1497
|
+
config.onProgress?.({
|
|
1498
|
+
type: "eval_start",
|
|
1499
|
+
evalName: evalDef.name,
|
|
1500
|
+
index: i,
|
|
1501
|
+
totalTurns: evalDef.conversation.length
|
|
1502
|
+
});
|
|
1503
|
+
const report = await runEval(evalDef, connection, {
|
|
1504
|
+
devServerUrl,
|
|
1505
|
+
devServerHeaders,
|
|
1506
|
+
evalIndex: i,
|
|
1507
|
+
...config.evalOptions?.idleTimeout !== void 0 ? { idleTimeout: config.evalOptions.idleTimeout } : {},
|
|
1508
|
+
...config.evalOptions?.judgePassThreshold !== void 0 ? { judgePassThreshold: config.evalOptions.judgePassThreshold } : {},
|
|
1509
|
+
...config.onProgress !== void 0 ? { onProgress: config.onProgress } : {},
|
|
1510
|
+
...config.signal !== void 0 ? { signal: config.signal } : {},
|
|
1511
|
+
...config.chatClient !== void 0 ? { chatClient: config.chatClient } : {}
|
|
1512
|
+
});
|
|
1513
|
+
reports.push(report);
|
|
1514
|
+
config.onProgress?.({ type: "eval_complete", evalName: evalDef.name, index: i, report });
|
|
1515
|
+
}
|
|
1516
|
+
const runReport = {
|
|
1517
|
+
id: runId,
|
|
1518
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1519
|
+
evals: reports,
|
|
1520
|
+
passed: reports.filter((r) => r.pass).length,
|
|
1521
|
+
failed: reports.filter((r) => !r.pass).length,
|
|
1522
|
+
total: reports.length,
|
|
1523
|
+
duration: Date.now() - start,
|
|
1524
|
+
...filter !== void 0 ? { filter } : {}
|
|
1525
|
+
};
|
|
1526
|
+
if (config.signal?.aborted) {
|
|
1527
|
+
runReport.aborted = true;
|
|
1528
|
+
}
|
|
1529
|
+
config.onProgress?.({ type: "suite_complete", report: runReport });
|
|
1530
|
+
return runReport;
|
|
1531
|
+
}
|
|
1532
|
+
export {
|
|
1533
|
+
runEval,
|
|
1534
|
+
runEvalSuite
|
|
1535
|
+
};
|
|
1536
|
+
//# sourceMappingURL=runner.js.map
|