@librechat/agents 3.3.7 → 3.3.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.
Files changed (168) hide show
  1. package/dist/cjs/graphs/Graph.cjs +47 -13
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/graphs/MultiAgentGraph.cjs +56 -6
  4. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  5. package/dist/cjs/hitl/askUserQuestion.cjs +3 -2
  6. package/dist/cjs/hitl/askUserQuestion.cjs.map +1 -1
  7. package/dist/cjs/instrumentation.cjs +18 -48
  8. package/dist/cjs/instrumentation.cjs.map +1 -1
  9. package/dist/cjs/langfuse.cjs +174 -29
  10. package/dist/cjs/langfuse.cjs.map +1 -1
  11. package/dist/cjs/langfuseConfig.cjs +12 -0
  12. package/dist/cjs/langfuseConfig.cjs.map +1 -1
  13. package/dist/cjs/langfuseRuntimeContext.cjs +23 -2
  14. package/dist/cjs/langfuseRuntimeContext.cjs.map +1 -1
  15. package/dist/cjs/langfuseRuntimeScope.cjs +39 -8
  16. package/dist/cjs/langfuseRuntimeScope.cjs.map +1 -1
  17. package/dist/cjs/langfuseSpanRegistry.cjs +95 -0
  18. package/dist/cjs/langfuseSpanRegistry.cjs.map +1 -0
  19. package/dist/cjs/langfuseTraceShaping.cjs +121 -4
  20. package/dist/cjs/langfuseTraceShaping.cjs.map +1 -1
  21. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +39 -15
  22. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
  23. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +25 -5
  24. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  25. package/dist/cjs/llm/init.cjs +3 -3
  26. package/dist/cjs/llm/invoke.cjs +5 -5
  27. package/dist/cjs/llm/openai/index.cjs +1 -1
  28. package/dist/cjs/main.cjs +10 -10
  29. package/dist/cjs/messages/format.cjs +124 -15
  30. package/dist/cjs/messages/format.cjs.map +1 -1
  31. package/dist/cjs/messages/injected.cjs +10 -1
  32. package/dist/cjs/messages/injected.cjs.map +1 -1
  33. package/dist/cjs/messages/prune.cjs +13 -1
  34. package/dist/cjs/messages/prune.cjs.map +1 -1
  35. package/dist/cjs/prompts/activityLabel.cjs +51 -11
  36. package/dist/cjs/prompts/activityLabel.cjs.map +1 -1
  37. package/dist/cjs/run.cjs +54 -24
  38. package/dist/cjs/run.cjs.map +1 -1
  39. package/dist/cjs/session/messageSerialization.cjs +6 -0
  40. package/dist/cjs/session/messageSerialization.cjs.map +1 -1
  41. package/dist/cjs/stream.cjs +21 -10
  42. package/dist/cjs/stream.cjs.map +1 -1
  43. package/dist/cjs/summarization/node.cjs +60 -0
  44. package/dist/cjs/summarization/node.cjs.map +1 -1
  45. package/dist/cjs/tools/ToolNode.cjs +253 -24
  46. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  47. package/dist/cjs/tools/handlers.cjs +1 -1
  48. package/dist/cjs/tools/search/tool.cjs +1 -1
  49. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -1
  50. package/dist/cjs/utils/index.cjs +2 -2
  51. package/dist/esm/graphs/Graph.mjs +48 -14
  52. package/dist/esm/graphs/Graph.mjs.map +1 -1
  53. package/dist/esm/graphs/MultiAgentGraph.mjs +56 -6
  54. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  55. package/dist/esm/hitl/askUserQuestion.mjs +3 -2
  56. package/dist/esm/hitl/askUserQuestion.mjs.map +1 -1
  57. package/dist/esm/instrumentation.mjs +18 -48
  58. package/dist/esm/instrumentation.mjs.map +1 -1
  59. package/dist/esm/langfuse.mjs +176 -28
  60. package/dist/esm/langfuse.mjs.map +1 -1
  61. package/dist/esm/langfuseConfig.mjs +10 -1
  62. package/dist/esm/langfuseConfig.mjs.map +1 -1
  63. package/dist/esm/langfuseRuntimeContext.mjs +21 -3
  64. package/dist/esm/langfuseRuntimeContext.mjs.map +1 -1
  65. package/dist/esm/langfuseRuntimeScope.mjs +39 -10
  66. package/dist/esm/langfuseRuntimeScope.mjs.map +1 -1
  67. package/dist/esm/langfuseSpanRegistry.mjs +91 -0
  68. package/dist/esm/langfuseSpanRegistry.mjs.map +1 -0
  69. package/dist/esm/langfuseTraceShaping.mjs +121 -4
  70. package/dist/esm/langfuseTraceShaping.mjs.map +1 -1
  71. package/dist/esm/llm/anthropic/utils/message_inputs.mjs +39 -15
  72. package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
  73. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +25 -5
  74. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  75. package/dist/esm/llm/init.mjs +2 -2
  76. package/dist/esm/llm/invoke.mjs +5 -5
  77. package/dist/esm/llm/openai/index.mjs +1 -1
  78. package/dist/esm/main.mjs +8 -8
  79. package/dist/esm/messages/format.mjs +124 -15
  80. package/dist/esm/messages/format.mjs.map +1 -1
  81. package/dist/esm/messages/injected.mjs +10 -1
  82. package/dist/esm/messages/injected.mjs.map +1 -1
  83. package/dist/esm/messages/prune.mjs +13 -1
  84. package/dist/esm/messages/prune.mjs.map +1 -1
  85. package/dist/esm/prompts/activityLabel.mjs +51 -11
  86. package/dist/esm/prompts/activityLabel.mjs.map +1 -1
  87. package/dist/esm/run.mjs +54 -24
  88. package/dist/esm/run.mjs.map +1 -1
  89. package/dist/esm/session/messageSerialization.mjs +6 -0
  90. package/dist/esm/session/messageSerialization.mjs.map +1 -1
  91. package/dist/esm/stream.mjs +21 -10
  92. package/dist/esm/stream.mjs.map +1 -1
  93. package/dist/esm/summarization/node.mjs +60 -0
  94. package/dist/esm/summarization/node.mjs.map +1 -1
  95. package/dist/esm/tools/ToolNode.mjs +254 -25
  96. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  97. package/dist/esm/tools/handlers.mjs +1 -1
  98. package/dist/esm/tools/search/tool.mjs +1 -1
  99. package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -1
  100. package/dist/esm/utils/index.mjs +2 -2
  101. package/dist/types/graphs/Graph.d.ts +19 -0
  102. package/dist/types/hitl/askUserQuestion.d.ts +11 -1
  103. package/dist/types/langfuse.d.ts +16 -8
  104. package/dist/types/langfuseConfig.d.ts +6 -0
  105. package/dist/types/langfuseRuntimeContext.d.ts +27 -1
  106. package/dist/types/langfuseRuntimeScope.d.ts +17 -2
  107. package/dist/types/langfuseSpanRegistry.d.ts +17 -0
  108. package/dist/types/langfuseTraceShaping.d.ts +2 -1
  109. package/dist/types/llm/anthropic/utils/message_inputs.d.ts +1 -0
  110. package/dist/types/messages/format.d.ts +9 -8
  111. package/dist/types/prompts/activityLabel.d.ts +8 -1
  112. package/dist/types/run.d.ts +1 -1
  113. package/dist/types/session/types.d.ts +1 -0
  114. package/dist/types/tools/ToolNode.d.ts +7 -1
  115. package/dist/types/types/activityLabel.d.ts +8 -0
  116. package/dist/types/types/hitl.d.ts +8 -0
  117. package/dist/types/types/stream.d.ts +19 -0
  118. package/dist/types/types/tools.d.ts +30 -0
  119. package/package.json +7 -4
  120. package/src/__tests__/stream.eagerArgsDivergence.test.ts +753 -0
  121. package/src/graphs/Graph.ts +69 -20
  122. package/src/graphs/MultiAgentGraph.ts +74 -6
  123. package/src/graphs/__tests__/composition.smoke.test.ts +4 -0
  124. package/src/hitl/askUserQuestion.ts +14 -1
  125. package/src/instrumentation.ts +35 -77
  126. package/src/langfuse.ts +320 -43
  127. package/src/langfuseConfig.ts +24 -0
  128. package/src/langfuseRuntimeContext.ts +43 -1
  129. package/src/langfuseRuntimeScope.ts +94 -21
  130. package/src/langfuseSpanRegistry.ts +131 -0
  131. package/src/langfuseTraceShaping.ts +194 -7
  132. package/src/llm/anthropic/utils/message_inputs.ts +70 -19
  133. package/src/llm/anthropic/utils/streaming-tool-input.test.ts +186 -11
  134. package/src/llm/bedrock/utils/message_inputs.test.ts +120 -4
  135. package/src/llm/bedrock/utils/message_inputs.ts +32 -7
  136. package/src/messages/format.ts +222 -50
  137. package/src/messages/formatAgentMessages.test.ts +308 -6
  138. package/src/messages/injected.test.ts +18 -1
  139. package/src/messages/injected.ts +8 -1
  140. package/src/messages/prune.ts +12 -1
  141. package/src/prompts/activityLabel.ts +67 -2
  142. package/src/run.ts +86 -46
  143. package/src/scripts/activity-labels/captured.json +56 -0
  144. package/src/scripts/activity-labels/checks.cjs +205 -0
  145. package/src/scripts/activity-labels/corpus.cjs +473 -0
  146. package/src/scripts/activity-labels/report.cjs +203 -0
  147. package/src/scripts/activity-labels/rescore.cjs +102 -0
  148. package/src/scripts/activity-labels/run.ts +705 -0
  149. package/src/scripts/activity-labels/variants.ts +71 -0
  150. package/src/session/messageSerialization.ts +12 -1
  151. package/src/session/types.ts +1 -0
  152. package/src/specs/activity-label-prompt.test.ts +109 -0
  153. package/src/specs/agent-handoffs.test.ts +306 -0
  154. package/src/specs/langfuse-callbacks.test.ts +456 -0
  155. package/src/specs/langfuse-routing.integration.test.ts +138 -1
  156. package/src/specs/langfuse-span-registry.test.ts +70 -0
  157. package/src/specs/langfuse-trace-shaping.test.ts +294 -0
  158. package/src/specs/prune.test.ts +38 -1
  159. package/src/stream.ts +70 -6
  160. package/src/summarization/__tests__/node.test.ts +188 -0
  161. package/src/summarization/node.ts +72 -0
  162. package/src/tools/ToolNode.ts +400 -9
  163. package/src/tools/__tests__/ToolNode.invalidToolCalls.test.ts +757 -0
  164. package/src/tools/__tests__/hitl.test.ts +58 -0
  165. package/src/types/activityLabel.ts +8 -0
  166. package/src/types/hitl.ts +8 -0
  167. package/src/types/stream.ts +20 -0
  168. package/src/types/tools.ts +35 -1
@@ -0,0 +1,473 @@
1
+ /**
2
+ * Eval corpus for activity-label prose. Two halves:
3
+ *
4
+ * - captured.json: the 9 real payloads from the 2026-07-29 sandbox-probe run,
5
+ * verbatim from Langfuse, replayed as ONE sequence so continuity variants
6
+ * see the same run shape production did. `productionLabel` is what shipped.
7
+ * - synthetic: cases built for the failure modes the captured run surfaced
8
+ * (redundant consecutive batches, register collapse, length overflow) plus
9
+ * the modes it never exercised (all-failed, partial, parallel columns,
10
+ * truncation, entry overflow, error-shaped success).
11
+ *
12
+ * A case is a sequence of steps; a step is one label request. Multi-step
13
+ * cases exist to measure cross-batch redundancy: the runner chains each
14
+ * step's generated label into the next step's `previousLabels` for variants
15
+ * that opt in.
16
+ */
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+
20
+ const captured = JSON.parse(
21
+ fs.readFileSync(path.join(__dirname, 'captured.json'), 'utf8')
22
+ );
23
+
24
+ const capturedRun = {
25
+ id: 'sandbox-probe-run',
26
+ notes: 'the real 9-batch production run, verbatim payloads',
27
+ steps: captured.map((entry) => ({
28
+ id: entry.id,
29
+ verbatim: entry.prompt,
30
+ productionLabel: entry.productionLabel,
31
+ })),
32
+ };
33
+
34
+ const synthetic = [
35
+ {
36
+ id: 'all-failed',
37
+ notes: 'every call fails — failure register, verb-first under failure',
38
+ steps: [
39
+ {
40
+ payload: {
41
+ lastAssistantText:
42
+ "I'll run each of these and report exactly what happens.",
43
+ entries: [
44
+ {
45
+ toolName: 'run_tools_with_bash',
46
+ toolInput: { code: 'cat /etc/shadow' },
47
+ status: 'error',
48
+ error: 'cat: /etc/shadow: Permission denied',
49
+ },
50
+ {
51
+ toolName: 'run_tools_with_bash',
52
+ toolInput: { code: 'ls /nonexistent-dir' },
53
+ status: 'error',
54
+ error:
55
+ "ls: cannot access '/nonexistent-dir': No such file or directory",
56
+ },
57
+ {
58
+ toolName: 'run_tools_with_bash',
59
+ toolInput: { code: 'curl -sS https://nope.invalid' },
60
+ status: 'error',
61
+ error: 'curl: (6) Could not resolve host: nope.invalid',
62
+ },
63
+ ],
64
+ },
65
+ },
66
+ ],
67
+ },
68
+ {
69
+ id: 'partial-failure',
70
+ notes: 'mixed batch — must not read as all-success or all-failure',
71
+ steps: [
72
+ {
73
+ payload: {
74
+ thinkingExcerpts: [
75
+ 'Three probes: create the marker dir, read the shadow file, resolve an invalid host. The first should work, the other two should fail for different reasons.',
76
+ ],
77
+ entries: [
78
+ {
79
+ toolName: 'run_tools_with_bash',
80
+ toolInput: { code: 'mkdir -p /tmp/probe && echo ok' },
81
+ toolOutput: 'stdout:\nok',
82
+ status: 'success',
83
+ },
84
+ {
85
+ toolName: 'run_tools_with_bash',
86
+ toolInput: { code: 'cat /etc/shadow' },
87
+ status: 'error',
88
+ error: 'cat: /etc/shadow: Permission denied',
89
+ },
90
+ {
91
+ toolName: 'run_tools_with_bash',
92
+ toolInput: { code: 'getent hosts nope.invalid' },
93
+ status: 'error',
94
+ error: 'exit code 2',
95
+ },
96
+ ],
97
+ },
98
+ },
99
+ ],
100
+ },
101
+ {
102
+ id: 'parallel-versions',
103
+ notes: 'one batch of parallel lookups — the groupId/parallel-columns shape',
104
+ steps: [
105
+ {
106
+ payload: {
107
+ lastAssistantText: 'Let me look up all three at once.',
108
+ entries: [
109
+ {
110
+ toolName: 'web_search',
111
+ toolInput: { query: 'Node.js latest stable version 2026' },
112
+ toolOutput:
113
+ 'Node.js 24.5.0 (Current) released 2026-07-22; v24 enters LTS October 2026. nodejs.org/en/blog/release/v24.5.0',
114
+ status: 'success',
115
+ },
116
+ {
117
+ toolName: 'web_search',
118
+ toolInput: { query: 'Deno latest release version' },
119
+ toolOutput:
120
+ 'Deno 2.4.2 released 2026-07-16 with improved node:sqlite compat. deno.com/blog/v2.4',
121
+ status: 'success',
122
+ },
123
+ {
124
+ toolName: 'web_search',
125
+ toolInput: { query: 'Bun latest release version' },
126
+ toolOutput:
127
+ 'Bun 1.2.19 released 2026-07-25, adds --compile cross-target for linux-arm64. bun.sh/blog/bun-v1.2.19',
128
+ status: 'success',
129
+ },
130
+ ],
131
+ },
132
+ },
133
+ ],
134
+ },
135
+ {
136
+ id: 'fib-rapid',
137
+ notes: 'three near-identical consecutive batches — redundancy stress',
138
+ steps: [1, 2, 3].map((n) => ({
139
+ id: `fib-${n}`,
140
+ payload: {
141
+ entries: [
142
+ {
143
+ toolName: 'execute_code',
144
+ toolInput: { code: `print(fib(${n}))` },
145
+ toolOutput: `stdout:\n${[1, 1, 2][n - 1]}`,
146
+ status: 'success',
147
+ },
148
+ ],
149
+ },
150
+ })),
151
+ },
152
+ {
153
+ id: 'mega-batch',
154
+ notes:
155
+ 'six heterogeneous probes in one batch — length-cap stress (mirrors cpu-meminfo-disk)',
156
+ steps: [
157
+ {
158
+ payload: {
159
+ thinkingExcerpts: [
160
+ "I'll gather the full system picture in one pass: CPU count, memory, disk, limits, user, kernel.",
161
+ ],
162
+ entries: [
163
+ {
164
+ toolName: 'run_tools_with_bash',
165
+ toolInput: { code: 'nproc' },
166
+ toolOutput: 'stdout:\n1',
167
+ status: 'success',
168
+ },
169
+ {
170
+ toolName: 'run_tools_with_bash',
171
+ toolInput: { code: 'cat /proc/meminfo | head -3' },
172
+ toolOutput: 'stdout:\n',
173
+ status: 'success',
174
+ },
175
+ {
176
+ toolName: 'run_tools_with_bash',
177
+ toolInput: { code: 'df -h / /tmp' },
178
+ toolOutput:
179
+ 'stdout:\nFilesystem Size Used Avail Use% Mounted on\noverlay 16M 12M 4.0M 75% /\ntmpfs 20M 0 20M 0% /tmp',
180
+ status: 'success',
181
+ },
182
+ {
183
+ toolName: 'run_tools_with_bash',
184
+ toolInput: { code: 'ulimit -v' },
185
+ toolOutput: 'stdout:\n16777216',
186
+ status: 'success',
187
+ },
188
+ {
189
+ toolName: 'run_tools_with_bash',
190
+ toolInput: { code: 'whoami' },
191
+ toolOutput: 'stdout:\nsandbox',
192
+ status: 'success',
193
+ },
194
+ {
195
+ toolName: 'run_tools_with_bash',
196
+ toolInput: { code: 'uname -r' },
197
+ toolOutput: 'stdout:\n6.1.102',
198
+ status: 'success',
199
+ },
200
+ ],
201
+ },
202
+ },
203
+ ],
204
+ },
205
+ {
206
+ id: 'single-trivial',
207
+ notes: 'one boring call — header must still say something the card cannot',
208
+ steps: [
209
+ {
210
+ payload: {
211
+ entries: [
212
+ {
213
+ toolName: 'run_tools_with_bash',
214
+ toolInput: { code: 'ls /mnt/data' },
215
+ toolOutput: 'stdout:\nnotes.md\nresults.csv\nprobe.txt',
216
+ status: 'success',
217
+ },
218
+ ],
219
+ },
220
+ },
221
+ ],
222
+ },
223
+ {
224
+ id: 'answer-found',
225
+ notes: 'the answer IS the line — a question resolved by one call',
226
+ steps: [
227
+ {
228
+ payload: {
229
+ lastAssistantText:
230
+ 'Let me find where that 30-second timeout is actually set.',
231
+ entries: [
232
+ {
233
+ toolName: 'grep',
234
+ toolInput: {
235
+ pattern: 'timeout',
236
+ path: 'api/server/utils/streams.js',
237
+ },
238
+ toolOutput:
239
+ 'streams.js:41: const STREAM_TIMEOUT_MS = 30_000; // hard cap per SSE flush\nstreams.js:88: setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS);',
240
+ status: 'success',
241
+ },
242
+ ],
243
+ },
244
+ },
245
+ ],
246
+ },
247
+ {
248
+ id: 'bare-batch',
249
+ notes: 'no intent, no reasoning — minimum context',
250
+ steps: [
251
+ {
252
+ payload: {
253
+ entries: [
254
+ {
255
+ toolName: 'read_file',
256
+ toolInput: { path: 'package.json' },
257
+ toolOutput:
258
+ '{\n "name": "librechat",\n "version": "0.8.1",\n ...',
259
+ status: 'success',
260
+ },
261
+ ],
262
+ },
263
+ },
264
+ ],
265
+ },
266
+ {
267
+ id: 'misleading-intent',
268
+ notes: 'intent asks one question, output answers it the other way',
269
+ steps: [
270
+ {
271
+ payload: {
272
+ lastAssistantText:
273
+ 'Now checking whether response caching is enabled in this deployment.',
274
+ entries: [
275
+ {
276
+ toolName: 'run_tools_with_bash',
277
+ toolInput: { code: 'grep -A2 "cache:" config/deploy.yaml' },
278
+ toolOutput: 'stdout:\ncache:\n enabled: false\n ttl: 3600',
279
+ status: 'success',
280
+ },
281
+ ],
282
+ },
283
+ },
284
+ ],
285
+ },
286
+ {
287
+ id: 'truncated-output',
288
+ notes: 'output clipped mid-JSON by the 600-char limit',
289
+ steps: [
290
+ {
291
+ payload: {
292
+ entries: [
293
+ {
294
+ toolName: 'run_tools_with_bash',
295
+ toolInput: { code: 'pip list --format=json' },
296
+ toolOutput:
297
+ 'stdout:\n' +
298
+ JSON.stringify(
299
+ Array.from({ length: 60 }, (_, i) => ({
300
+ name: `package-${i}`,
301
+ version: `1.${i}.0`,
302
+ }))
303
+ ),
304
+ status: 'success',
305
+ },
306
+ ],
307
+ },
308
+ },
309
+ ],
310
+ },
311
+ {
312
+ id: 'silent-success',
313
+ notes: 'empty output — nothing came back to summarize',
314
+ steps: [
315
+ {
316
+ payload: {
317
+ lastAssistantText: "I'll write the results file now.",
318
+ entries: [
319
+ {
320
+ toolName: 'write_file',
321
+ toolInput: {
322
+ path: '/mnt/data/results.csv',
323
+ content: 'run,ms\n1,412\n2,398\n',
324
+ },
325
+ toolOutput: '',
326
+ status: 'success',
327
+ },
328
+ ],
329
+ },
330
+ },
331
+ ],
332
+ },
333
+ {
334
+ id: 'edit-verify',
335
+ notes: 'edit plus read-back in one batch — one activity, two calls',
336
+ steps: [
337
+ {
338
+ payload: {
339
+ thinkingExcerpts: [
340
+ 'The retry cap is what causes the duplicate sends; dropping it from 5 to 1 and verifying the file took the change.',
341
+ ],
342
+ entries: [
343
+ {
344
+ toolName: 'edit_file',
345
+ toolInput: {
346
+ path: 'api/server/utils/queue.js',
347
+ old: 'const MAX_RETRIES = 5;',
348
+ new: 'const MAX_RETRIES = 1;',
349
+ },
350
+ toolOutput: 'OK',
351
+ status: 'success',
352
+ },
353
+ {
354
+ toolName: 'read_file',
355
+ toolInput: { path: 'api/server/utils/queue.js', range: [10, 14] },
356
+ toolOutput: 'const MAX_RETRIES = 1;\nconst BACKOFF_MS = 250;',
357
+ status: 'success',
358
+ },
359
+ ],
360
+ },
361
+ },
362
+ ],
363
+ },
364
+ {
365
+ id: 'error-shaped-success',
366
+ notes:
367
+ 'tool returns an error payload with success status — must not read as success',
368
+ steps: [
369
+ {
370
+ payload: {
371
+ lastAssistantText: 'Searching for the changelog now.',
372
+ entries: [
373
+ {
374
+ toolName: 'web_search',
375
+ toolInput: { query: 'librechat 0.8.1 changelog' },
376
+ toolOutput:
377
+ '{"error":{"code":"rate_limited","message":"Search quota exceeded, retry after 3600s"}}',
378
+ status: 'success',
379
+ },
380
+ ],
381
+ },
382
+ },
383
+ ],
384
+ },
385
+ {
386
+ id: 'mcp-long-name',
387
+ notes: 'namespaced MCP tool name — echo temptation',
388
+ steps: [
389
+ {
390
+ payload: {
391
+ entries: [
392
+ {
393
+ toolName: 'mcp__github__search_repositories',
394
+ toolInput: { query: 'org:danny-avila librechat-agents' },
395
+ toolOutput:
396
+ '{"total_count":2,"items":[{"full_name":"danny-avila/LibreChat","stars":31200},{"full_name":"danny-avila/agents","stars":410}]}',
397
+ status: 'success',
398
+ },
399
+ ],
400
+ },
401
+ },
402
+ ],
403
+ },
404
+ {
405
+ id: 'dup-activity-seq',
406
+ notes:
407
+ 'controlled mirror of captured steps 2/3 — same activity twice in a row',
408
+ steps: [
409
+ {
410
+ id: 'dup-write',
411
+ payload: {
412
+ thinkingExcerpts: [
413
+ 'First write a marker file, then a separate call will check it survives.',
414
+ ],
415
+ entries: [
416
+ {
417
+ toolName: 'run_tools_with_bash',
418
+ toolInput: {
419
+ code: 'echo "marker-$(date +%s)" > /tmp/persist-probe.txt && cat /tmp/persist-probe.txt',
420
+ },
421
+ toolOutput: 'stdout:\nmarker-1785932011',
422
+ status: 'success',
423
+ },
424
+ ],
425
+ },
426
+ },
427
+ {
428
+ id: 'dup-confirm',
429
+ payload: {
430
+ entries: [
431
+ {
432
+ toolName: 'run_tools_with_bash',
433
+ toolInput: { code: 'cat /tmp/persist-probe.txt' },
434
+ toolOutput: 'stdout:\nmarker-1785932011',
435
+ status: 'success',
436
+ },
437
+ ],
438
+ },
439
+ },
440
+ ],
441
+ },
442
+ {
443
+ id: 'overflow-entries',
444
+ notes: '14 calls — exercises the 12-entry cap and the "…and 2 more" suffix',
445
+ steps: [
446
+ {
447
+ payload: {
448
+ entries: Array.from({ length: 14 }, (_, i) => ({
449
+ toolName: 'run_tools_with_bash',
450
+ toolInput: { code: `convert page-${i + 1}.svg page-${i + 1}.png` },
451
+ toolOutput: '',
452
+ status: 'success',
453
+ })),
454
+ },
455
+ },
456
+ ],
457
+ },
458
+ ];
459
+
460
+ /** Tool names for echo checks; captured steps bake entries into the
461
+ * verbatim prompt, so they are recovered from the "Tool calls:" lines. */
462
+ function stepEntries(step) {
463
+ if (step.payload?.entries) {
464
+ return step.payload.entries;
465
+ }
466
+ return [...(step.verbatim ?? '').matchAll(/^- ([A-Za-z0-9_]+)\(/gm)].map(
467
+ (match) => ({
468
+ toolName: match[1],
469
+ })
470
+ );
471
+ }
472
+
473
+ module.exports = { cases: [capturedRun, ...synthetic], stepEntries };
@@ -0,0 +1,203 @@
1
+ /** Aggregation + markdown rendering, shared by the live runner and the
2
+ * offline rescorer so metric fixes never require re-spending on the API.
3
+ *
4
+ * Ported from LibreChat #14527 with three fixes pending backport: numeric
5
+ * sample ordering, a keyed record index for the per-case cells, and
6
+ * case-folded opener tallies. */
7
+ const FLAG_TYPES = [
8
+ 'len',
9
+ 'punct',
10
+ 'quote',
11
+ 'md',
12
+ 'opener',
13
+ 'tool-echo',
14
+ 'count-echo',
15
+ 'restate',
16
+ 'template',
17
+ ];
18
+
19
+ const PRICES = { 'claude-haiku-4-5': { input: 1, output: 5 } };
20
+
21
+ function flagType(flag) {
22
+ return flag.split(':')[0];
23
+ }
24
+
25
+ function aggregate(records, model) {
26
+ const byVariant = new Map();
27
+ for (const record of records) {
28
+ if (!byVariant.has(record.variant)) {
29
+ byVariant.set(record.variant, {
30
+ steps: 0,
31
+ errors: 0,
32
+ flagCounts: {},
33
+ /** Null prototype: labels are model prose, and an opener like
34
+ * 'Constructor' would otherwise hit Object.prototype.constructor
35
+ * and render a garbage tally ('__proto__' would vanish). */
36
+ firstWords: Object.create(null),
37
+ totalWords: 0,
38
+ latencies: [],
39
+ inputTokens: 0,
40
+ outputTokens: 0,
41
+ });
42
+ }
43
+ const agg = byVariant.get(record.variant);
44
+ if (record.error) {
45
+ agg.errors += 1;
46
+ /** Some failures still billed (a 200 whose label normalized to
47
+ * empty) — dropping their usage would make an all-empty variant
48
+ * report $0, and dropping latency would show mean ms 0. Old
49
+ * stored runs predate error latency; skip when absent. */
50
+ agg.inputTokens += record.inputTokens ?? 0;
51
+ agg.outputTokens += record.outputTokens ?? 0;
52
+ if (record.latencyMs != null) {
53
+ agg.latencies.push(record.latencyMs);
54
+ }
55
+ continue;
56
+ }
57
+ agg.steps += 1;
58
+ agg.totalWords += record.wordCount;
59
+ agg.latencies.push(record.latencyMs);
60
+ agg.inputTokens += record.inputTokens;
61
+ agg.outputTokens += record.outputTokens;
62
+ /** Case-folded: 'Found' and 'found' are one opener — counting them
63
+ * separately would make a sentence-case violation read as MORE
64
+ * register diversity, the opposite of what the tally detects. */
65
+ const opener =
66
+ typeof record.firstWord === 'string'
67
+ ? record.firstWord.toLowerCase()
68
+ : record.firstWord;
69
+ agg.firstWords[opener] = (agg.firstWords[opener] ?? 0) + 1;
70
+ for (const flag of record.flags) {
71
+ const type = flagType(flag);
72
+ agg.flagCounts[type] = (agg.flagCounts[type] ?? 0) + 1;
73
+ }
74
+ }
75
+ const price = PRICES[model];
76
+ return [...byVariant.entries()].map(([name, agg]) => {
77
+ const sortedFirst = Object.entries(agg.firstWords).sort(
78
+ (a, b) => b[1] - a[1]
79
+ );
80
+ const topOpener = sortedFirst[0] ?? ['—', 0];
81
+ return {
82
+ variant: name,
83
+ steps: agg.steps,
84
+ errors: agg.errors,
85
+ flagCounts: agg.flagCounts,
86
+ distinctOpeners: sortedFirst.length,
87
+ topOpener: `${topOpener[0]} ×${topOpener[1]}`,
88
+ avgWords: agg.steps > 0 ? (agg.totalWords / agg.steps).toFixed(1) : '—',
89
+ meanLatencyMs: agg.latencies.length
90
+ ? Math.round(
91
+ agg.latencies.reduce((a, b) => a + b, 0) / agg.latencies.length
92
+ )
93
+ : 0,
94
+ inputTokens: agg.inputTokens,
95
+ outputTokens: agg.outputTokens,
96
+ costUsd: price
97
+ ? (
98
+ (agg.inputTokens * price.input + agg.outputTokens * price.output) /
99
+ 1e6
100
+ ).toFixed(4)
101
+ : 'n/a',
102
+ };
103
+ });
104
+ }
105
+
106
+ function markdownReport({
107
+ records,
108
+ aggregates,
109
+ runCases,
110
+ variantNames,
111
+ model,
112
+ samples,
113
+ }) {
114
+ const lines = [];
115
+ lines.push(`# Activity-label eval — ${new Date().toISOString()}`);
116
+ lines.push('');
117
+ lines.push(
118
+ `model: \`${model}\` · samples: ${samples} · cases: ${runCases.length}`
119
+ );
120
+ lines.push('');
121
+ lines.push('## Aggregate');
122
+ lines.push('');
123
+ lines.push(
124
+ `| variant | steps | ${FLAG_TYPES.join(' | ')} | distinct openers | top opener | avg words | mean ms | cost |`
125
+ );
126
+ lines.push(
127
+ `|---|---:|${FLAG_TYPES.map(() => '---:').join('|')}|---:|---|---:|---:|---:|`
128
+ );
129
+ for (const agg of aggregates) {
130
+ lines.push(
131
+ `| ${agg.variant} | ${agg.steps}${agg.errors ? ` (+${agg.errors} err)` : ''} | ` +
132
+ FLAG_TYPES.map((type) => agg.flagCounts[type] ?? 0).join(' | ') +
133
+ /** topOpener carries a model-derived token — escape pipes like
134
+ * the per-case renderer does, or a label opening with `a|b`
135
+ * shifts every following aggregate column. */
136
+ ` | ${agg.distinctOpeners} | ${String(agg.topOpener).replace(/\|/g, '\\|')} | ${agg.avgWords} | ${agg.meanLatencyMs} | $${agg.costUsd} |`
137
+ );
138
+ }
139
+ lines.push('');
140
+ lines.push('## Per-case');
141
+ /** Sorted numerically — the default lexicographic sort orders samples
142
+ * 1, 10, 11, …, 2 once a sweep reaches ten samples. */
143
+ const sampleList = [...new Set(records.map((r) => r.sample))].sort(
144
+ (a, b) => a - b
145
+ );
146
+ /** One keyed pass instead of a records.find per table cell, which is
147
+ * quadratic in paid results on large sweeps. */
148
+ const recordIndex = new Map(
149
+ records.map((record) => [
150
+ `${record.variant}\0${record.sample}\0${record.caseId}\0${record.stepId}`,
151
+ record,
152
+ ])
153
+ );
154
+ for (const testCase of runCases) {
155
+ lines.push('');
156
+ lines.push(`### ${testCase.id}`);
157
+ lines.push('');
158
+ lines.push(`*${testCase.notes}*`);
159
+ lines.push('');
160
+ const header = ['step'];
161
+ if (samples > 1) {
162
+ header.push('s');
163
+ }
164
+ if (testCase.steps.some((step) => step.productionLabel)) {
165
+ header.push('production');
166
+ }
167
+ header.push(...variantNames);
168
+ lines.push(`| ${header.join(' | ')} |`);
169
+ lines.push(`|${header.map(() => '---').join('|')}|`);
170
+ for (const step of testCase.steps) {
171
+ const stepId = step.id ?? testCase.id;
172
+ for (const sample of sampleList) {
173
+ const row = [stepId];
174
+ if (samples > 1) {
175
+ row.push(String(sample));
176
+ }
177
+ if (header.includes('production')) {
178
+ row.push(step.productionLabel ?? '');
179
+ }
180
+ for (const variantName of variantNames) {
181
+ const record = recordIndex.get(
182
+ `${variantName}\0${sample}\0${testCase.id}\0${stepId}`
183
+ );
184
+ if (!record) {
185
+ row.push('');
186
+ } else if (record.error) {
187
+ row.push(`⛔ ${record.error}`);
188
+ } else {
189
+ const flagNote =
190
+ record.flags.length > 0 ? ` ⚠${record.flags.join(' ⚠')}` : '';
191
+ row.push(`${record.label}${flagNote}`);
192
+ }
193
+ }
194
+ lines.push(
195
+ `| ${row.map((cell) => cell.replace(/\|/g, '\\|')).join(' | ')} |`
196
+ );
197
+ }
198
+ }
199
+ }
200
+ return lines.join('\n') + '\n';
201
+ }
202
+
203
+ module.exports = { aggregate, markdownReport, FLAG_TYPES };