@librechat/agents 3.4.1 → 3.4.2

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 (128) hide show
  1. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  2. package/dist/cjs/graphs/Graph.cjs +57 -33
  3. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  4. package/dist/cjs/graphs/MultiAgentGraph.cjs +81 -33
  5. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  6. package/dist/cjs/graphs/applyGraphRuntimeConfig.cjs +14 -0
  7. package/dist/cjs/graphs/applyGraphRuntimeConfig.cjs.map +1 -0
  8. package/dist/cjs/graphs/createGraph.cjs +15 -0
  9. package/dist/cjs/graphs/createGraph.cjs.map +1 -0
  10. package/dist/cjs/graphs/index.cjs +1 -0
  11. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +1 -1
  12. package/dist/cjs/main.cjs +20 -11
  13. package/dist/cjs/run.cjs +48 -38
  14. package/dist/cjs/run.cjs.map +1 -1
  15. package/dist/cjs/session/JsonlSessionStore.cjs +2 -2
  16. package/dist/cjs/tools/SubagentTool.cjs +4 -4
  17. package/dist/cjs/tools/SubagentTool.cjs.map +1 -1
  18. package/dist/cjs/tools/ToolNode.cjs +13 -9
  19. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  20. package/dist/cjs/tools/eagerEventExecution.cjs +1 -0
  21. package/dist/cjs/tools/local/LocalExecutionEngine.cjs +3 -3
  22. package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs +2 -2
  23. package/dist/cjs/tools/search/rerankers.cjs +154 -1
  24. package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
  25. package/dist/cjs/tools/search/tool.cjs +4 -1
  26. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  27. package/dist/cjs/tools/subagent/SubagentExecutionRegistry.cjs +601 -0
  28. package/dist/cjs/tools/subagent/SubagentExecutionRegistry.cjs.map +1 -0
  29. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +672 -343
  30. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  31. package/dist/cjs/tools/subagent/SubagentReplay.cjs +3 -1
  32. package/dist/cjs/tools/subagent/SubagentReplay.cjs.map +1 -1
  33. package/dist/cjs/tools/subagent/childGraphConfig.cjs +278 -0
  34. package/dist/cjs/tools/subagent/childGraphConfig.cjs.map +1 -0
  35. package/dist/cjs/tools/subagent/index.cjs +1 -0
  36. package/dist/cjs/tools/subagent/runtimeLimits.cjs +5 -0
  37. package/dist/cjs/tools/subagent/runtimeLimits.cjs.map +1 -0
  38. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  39. package/dist/esm/graphs/Graph.mjs +57 -33
  40. package/dist/esm/graphs/Graph.mjs.map +1 -1
  41. package/dist/esm/graphs/MultiAgentGraph.mjs +81 -33
  42. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  43. package/dist/esm/graphs/applyGraphRuntimeConfig.mjs +14 -0
  44. package/dist/esm/graphs/applyGraphRuntimeConfig.mjs.map +1 -0
  45. package/dist/esm/graphs/createGraph.mjs +15 -0
  46. package/dist/esm/graphs/createGraph.mjs.map +1 -0
  47. package/dist/esm/graphs/index.mjs +1 -0
  48. package/dist/esm/hooks/createWorkspacePolicyHook.mjs +1 -1
  49. package/dist/esm/main.mjs +12 -10
  50. package/dist/esm/run.mjs +48 -38
  51. package/dist/esm/run.mjs.map +1 -1
  52. package/dist/esm/session/JsonlSessionStore.mjs +2 -2
  53. package/dist/esm/tools/SubagentTool.mjs +4 -4
  54. package/dist/esm/tools/SubagentTool.mjs.map +1 -1
  55. package/dist/esm/tools/ToolNode.mjs +13 -9
  56. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  57. package/dist/esm/tools/eagerEventExecution.mjs +1 -1
  58. package/dist/esm/tools/local/LocalExecutionEngine.mjs +3 -3
  59. package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs +2 -2
  60. package/dist/esm/tools/search/rerankers.mjs +154 -1
  61. package/dist/esm/tools/search/rerankers.mjs.map +1 -1
  62. package/dist/esm/tools/search/tool.mjs +4 -1
  63. package/dist/esm/tools/search/tool.mjs.map +1 -1
  64. package/dist/esm/tools/subagent/SubagentExecutionRegistry.mjs +597 -0
  65. package/dist/esm/tools/subagent/SubagentExecutionRegistry.mjs.map +1 -0
  66. package/dist/esm/tools/subagent/SubagentExecutor.mjs +672 -343
  67. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  68. package/dist/esm/tools/subagent/SubagentReplay.mjs +3 -1
  69. package/dist/esm/tools/subagent/SubagentReplay.mjs.map +1 -1
  70. package/dist/esm/tools/subagent/childGraphConfig.mjs +272 -0
  71. package/dist/esm/tools/subagent/childGraphConfig.mjs.map +1 -0
  72. package/dist/esm/tools/subagent/index.mjs +1 -0
  73. package/dist/esm/tools/subagent/runtimeLimits.mjs +5 -0
  74. package/dist/esm/tools/subagent/runtimeLimits.mjs.map +1 -0
  75. package/dist/types/agents/AgentContext.d.ts +1 -1
  76. package/dist/types/graphs/Graph.d.ts +7 -2
  77. package/dist/types/graphs/MultiAgentGraph.d.ts +7 -1
  78. package/dist/types/graphs/applyGraphRuntimeConfig.d.ts +3 -0
  79. package/dist/types/graphs/createGraph.d.ts +13 -0
  80. package/dist/types/graphs/graphFactory.d.ts +13 -0
  81. package/dist/types/graphs/index.d.ts +2 -0
  82. package/dist/types/run.d.ts +2 -2
  83. package/dist/types/tools/SubagentTool.d.ts +1 -1
  84. package/dist/types/tools/search/rerankers.d.ts +28 -0
  85. package/dist/types/tools/search/types.d.ts +38 -1
  86. package/dist/types/tools/subagent/SubagentExecutionRegistry.d.ts +171 -0
  87. package/dist/types/tools/subagent/SubagentExecutor.d.ts +32 -37
  88. package/dist/types/tools/subagent/SubagentReplay.d.ts +5 -1
  89. package/dist/types/tools/subagent/childGraphConfig.d.ts +37 -0
  90. package/dist/types/tools/subagent/index.d.ts +1 -1
  91. package/dist/types/tools/subagent/runtimeLimits.d.ts +4 -0
  92. package/dist/types/types/graph.d.ts +184 -19
  93. package/package.json +3 -1
  94. package/src/agents/AgentContext.ts +1 -1
  95. package/src/graphs/Graph.ts +111 -60
  96. package/src/graphs/MultiAgentGraph.ts +149 -47
  97. package/src/graphs/__tests__/MultiAgentGraph.test.ts +86 -0
  98. package/src/graphs/__tests__/composition.smoke.test.ts +152 -0
  99. package/src/graphs/__tests__/createGraph.test.ts +206 -0
  100. package/src/graphs/applyGraphRuntimeConfig.ts +25 -0
  101. package/src/graphs/createGraph.ts +32 -0
  102. package/src/graphs/graphFactory.ts +12 -0
  103. package/src/graphs/index.ts +2 -0
  104. package/src/run.ts +50 -38
  105. package/src/specs/graph-subagent.live.test.ts +242 -0
  106. package/src/specs/graph-subagent.test.ts +322 -0
  107. package/src/specs/langfuse-routing.integration.test.ts +120 -1
  108. package/src/specs/subagent.test.ts +130 -4
  109. package/src/tools/SubagentTool.ts +4 -4
  110. package/src/tools/ToolNode.ts +22 -10
  111. package/src/tools/__tests__/SubagentExecutor.test.ts +198 -9
  112. package/src/tools/__tests__/SubagentReplay.test.ts +29 -0
  113. package/src/tools/__tests__/directToolHITLResumeScope.test.ts +28 -2
  114. package/src/tools/__tests__/subagentHooks.test.ts +90 -3
  115. package/src/tools/search/rag-api-reranker.test.ts +891 -0
  116. package/src/tools/search/rerankers.ts +260 -0
  117. package/src/tools/search/tool.ts +6 -0
  118. package/src/tools/search/types.ts +45 -1
  119. package/src/tools/subagent/SubagentExecutionRegistry.ts +1159 -0
  120. package/src/tools/subagent/SubagentExecutor.ts +1245 -563
  121. package/src/tools/subagent/SubagentReplay.ts +10 -1
  122. package/src/tools/subagent/__tests__/SubagentExecutionRegistry.test.ts +637 -0
  123. package/src/tools/subagent/__tests__/SubagentExecutor.lazy.test.ts +3054 -0
  124. package/src/tools/subagent/__tests__/childGraphConfig.test.ts +521 -0
  125. package/src/tools/subagent/childGraphConfig.ts +694 -0
  126. package/src/tools/subagent/index.ts +6 -0
  127. package/src/tools/subagent/runtimeLimits.ts +7 -0
  128. package/src/types/graph.ts +217 -19
@@ -0,0 +1,891 @@
1
+ import axios from 'axios';
2
+ import type * as t from './types';
3
+ import { createReranker, RagApiReranker } from './rerankers';
4
+ import { createDefaultLogger } from './utils';
5
+
6
+ describe('RagApiReranker', () => {
7
+ const mockLogger = createDefaultLogger();
8
+ const baseUrl = 'https://rag.example.com';
9
+
10
+ afterEach(() => {
11
+ jest.restoreAllMocks();
12
+ });
13
+
14
+ describe('constructor', () => {
15
+ it('should use RAG_API_URL when no baseUrl is provided', async () => {
16
+ const originalEnv = process.env.RAG_API_URL;
17
+ process.env.RAG_API_URL = 'https://env-rag-endpoint.com';
18
+
19
+ const tokenSupplier = jest.fn().mockResolvedValue('token');
20
+ const reranker = new RagApiReranker({
21
+ tokenSupplier,
22
+ logger: mockLogger,
23
+ });
24
+ jest.spyOn(mockLogger, 'debug').mockImplementation(() => mockLogger);
25
+ const postSpy = jest.spyOn(axios, 'post').mockResolvedValueOnce({
26
+ data: {
27
+ profile: 'fast-v1',
28
+ model: 'embed-blend-v0',
29
+ results: [{ id: '0', index: 0, score: 0.9 }],
30
+ },
31
+ });
32
+
33
+ await reranker.rerank('query', ['doc1'], 1);
34
+
35
+ expect(postSpy).toHaveBeenCalledWith(
36
+ 'https://env-rag-endpoint.com/v1/rerank',
37
+ expect.any(Object),
38
+ expect.any(Object)
39
+ );
40
+
41
+ if (typeof originalEnv === 'string') {
42
+ process.env.RAG_API_URL = originalEnv;
43
+ } else {
44
+ delete process.env.RAG_API_URL;
45
+ }
46
+ });
47
+
48
+ it('should prioritize an explicit baseUrl over RAG_API_URL', async () => {
49
+ const originalEnv = process.env.RAG_API_URL;
50
+ process.env.RAG_API_URL = 'https://env-rag-endpoint.com';
51
+
52
+ const tokenSupplier = jest.fn().mockResolvedValue('token');
53
+ const reranker = new RagApiReranker({
54
+ baseUrl,
55
+ tokenSupplier,
56
+ logger: mockLogger,
57
+ });
58
+ jest.spyOn(mockLogger, 'debug').mockImplementation(() => mockLogger);
59
+ const postSpy = jest.spyOn(axios, 'post').mockResolvedValueOnce({
60
+ data: { results: [{ id: '0', index: 0, score: 0.9 }] },
61
+ });
62
+
63
+ await reranker.rerank('query', ['doc1'], 1);
64
+
65
+ expect(postSpy).toHaveBeenCalledWith(
66
+ `${baseUrl}/v1/rerank`,
67
+ expect.any(Object),
68
+ expect.any(Object)
69
+ );
70
+
71
+ if (typeof originalEnv === 'string') {
72
+ process.env.RAG_API_URL = originalEnv;
73
+ } else {
74
+ delete process.env.RAG_API_URL;
75
+ }
76
+ });
77
+
78
+ it('should strip a trailing slash from an explicit baseUrl', async () => {
79
+ const tokenSupplier = jest.fn().mockResolvedValue('token');
80
+ const reranker = new RagApiReranker({
81
+ baseUrl: 'https://rag.example.com/',
82
+ tokenSupplier,
83
+ logger: mockLogger,
84
+ });
85
+ jest.spyOn(mockLogger, 'debug').mockImplementation(() => mockLogger);
86
+ const postSpy = jest.spyOn(axios, 'post').mockResolvedValueOnce({
87
+ data: { results: [{ id: '0', index: 0, score: 0.9 }] },
88
+ });
89
+
90
+ await reranker.rerank('query', ['document1'], 1);
91
+
92
+ expect(postSpy).toHaveBeenCalledWith(
93
+ 'https://rag.example.com/v1/rerank',
94
+ expect.any(Object),
95
+ expect.any(Object)
96
+ );
97
+ });
98
+
99
+ it('should strip trailing slashes from RAG_API_URL', async () => {
100
+ const originalEnv = process.env.RAG_API_URL;
101
+ process.env.RAG_API_URL = 'https://env-rag-endpoint.com//';
102
+
103
+ const tokenSupplier = jest.fn().mockResolvedValue('token');
104
+ const reranker = new RagApiReranker({
105
+ tokenSupplier,
106
+ logger: mockLogger,
107
+ });
108
+ jest.spyOn(mockLogger, 'debug').mockImplementation(() => mockLogger);
109
+ const postSpy = jest.spyOn(axios, 'post').mockResolvedValueOnce({
110
+ data: { results: [{ id: '0', index: 0, score: 0.9 }] },
111
+ });
112
+
113
+ await reranker.rerank('query', ['document1'], 1);
114
+
115
+ expect(postSpy).toHaveBeenCalledWith(
116
+ 'https://env-rag-endpoint.com/v1/rerank',
117
+ expect.any(Object),
118
+ expect.any(Object)
119
+ );
120
+
121
+ if (typeof originalEnv === 'string') {
122
+ process.env.RAG_API_URL = originalEnv;
123
+ } else {
124
+ delete process.env.RAG_API_URL;
125
+ }
126
+ });
127
+ });
128
+
129
+ describe('rerank method', () => {
130
+ it('should return [] without a network call when there are no documents', async () => {
131
+ const tokenSupplier = jest.fn().mockResolvedValue('token');
132
+ const reranker = new RagApiReranker({
133
+ baseUrl,
134
+ tokenSupplier,
135
+ logger: mockLogger,
136
+ });
137
+ const postSpy = jest.spyOn(axios, 'post');
138
+
139
+ const result = await reranker.rerank('query', [], 5);
140
+
141
+ expect(result).toEqual([]);
142
+ expect(postSpy).not.toHaveBeenCalled();
143
+ expect(tokenSupplier).not.toHaveBeenCalled();
144
+ });
145
+
146
+ it('should fall back to default ranking without a network call when baseUrl is missing', async () => {
147
+ const originalEnv = process.env.RAG_API_URL;
148
+ delete process.env.RAG_API_URL;
149
+
150
+ const tokenSupplier = jest.fn().mockResolvedValue('token');
151
+ const reranker = new RagApiReranker({ tokenSupplier, logger: mockLogger });
152
+ const warnSpy = jest
153
+ .spyOn(mockLogger, 'warn')
154
+ .mockImplementation(() => mockLogger);
155
+ const postSpy = jest.spyOn(axios, 'post');
156
+
157
+ const result = await reranker.rerank(
158
+ 'query',
159
+ ['document1', 'document2'],
160
+ 2
161
+ );
162
+
163
+ expect(result).toEqual([
164
+ { text: 'document1', score: 0 },
165
+ { text: 'document2', score: 0 },
166
+ ]);
167
+ expect(postSpy).not.toHaveBeenCalled();
168
+ expect(warnSpy).toHaveBeenCalledWith(
169
+ 'RAG_API_URL is not set. Using default ranking.'
170
+ );
171
+
172
+ if (typeof originalEnv === 'string') {
173
+ process.env.RAG_API_URL = originalEnv;
174
+ }
175
+ });
176
+
177
+ it('should fall back to default ranking without a network call when no token supplier is configured', async () => {
178
+ const reranker = new RagApiReranker({ baseUrl, logger: mockLogger });
179
+ const warnSpy = jest
180
+ .spyOn(mockLogger, 'warn')
181
+ .mockImplementation(() => mockLogger);
182
+ const postSpy = jest.spyOn(axios, 'post');
183
+
184
+ const result = await reranker.rerank(
185
+ 'query',
186
+ ['document1', 'document2'],
187
+ 2
188
+ );
189
+
190
+ expect(result).toEqual([
191
+ { text: 'document1', score: 0 },
192
+ { text: 'document2', score: 0 },
193
+ ]);
194
+ expect(postSpy).not.toHaveBeenCalled();
195
+ expect(warnSpy).toHaveBeenCalledWith(
196
+ 'No rag_api token supplier configured. Using default ranking.'
197
+ );
198
+ });
199
+
200
+ it('should call the token supplier per request and send it as a bearer token', async () => {
201
+ const tokenSupplier = jest
202
+ .fn<Promise<string>, []>()
203
+ .mockResolvedValueOnce('jwt-1')
204
+ .mockResolvedValueOnce('jwt-2');
205
+ const reranker = new RagApiReranker({
206
+ baseUrl,
207
+ tokenSupplier,
208
+ logger: mockLogger,
209
+ });
210
+ jest.spyOn(mockLogger, 'debug').mockImplementation(() => mockLogger);
211
+ const postSpy = jest.spyOn(axios, 'post').mockResolvedValue({
212
+ data: { results: [{ id: '0', index: 0, score: 0.9 }] },
213
+ });
214
+
215
+ await reranker.rerank('query', ['document1'], 1);
216
+ await reranker.rerank('query', ['document1'], 1);
217
+
218
+ expect(tokenSupplier).toHaveBeenCalledTimes(2);
219
+ expect(postSpy).toHaveBeenNthCalledWith(
220
+ 1,
221
+ expect.any(String),
222
+ expect.any(Object),
223
+ expect.objectContaining({
224
+ headers: expect.objectContaining({ Authorization: 'Bearer jwt-1' }),
225
+ })
226
+ );
227
+ expect(postSpy).toHaveBeenNthCalledWith(
228
+ 2,
229
+ expect.any(String),
230
+ expect.any(Object),
231
+ expect.objectContaining({
232
+ headers: expect.objectContaining({ Authorization: 'Bearer jwt-2' }),
233
+ })
234
+ );
235
+ });
236
+
237
+ it('should send the fast-v1 candidates/top_n contract shape', async () => {
238
+ const tokenSupplier = jest.fn().mockResolvedValue('token');
239
+ const reranker = new RagApiReranker({
240
+ baseUrl,
241
+ tokenSupplier,
242
+ logger: mockLogger,
243
+ });
244
+ jest.spyOn(mockLogger, 'debug').mockImplementation(() => mockLogger);
245
+ const postSpy = jest.spyOn(axios, 'post').mockResolvedValueOnce({
246
+ data: {
247
+ results: [
248
+ { id: '0', index: 0, score: 0.5 },
249
+ { id: '1', index: 1, score: 0.9 },
250
+ ],
251
+ },
252
+ });
253
+
254
+ await reranker.rerank('search query', ['document1', 'document2'], 2);
255
+
256
+ const [, requestBody] = postSpy.mock.calls[0];
257
+ expect(requestBody).toEqual({
258
+ profile: 'fast-v1',
259
+ query: 'search query',
260
+ candidates: [
261
+ { id: '0', text: 'document1', base_score: 0 },
262
+ { id: '1', text: 'document2', base_score: 0 },
263
+ ],
264
+ top_n: 2,
265
+ });
266
+ });
267
+
268
+ it('should use a custom profile when provided', async () => {
269
+ const tokenSupplier = jest.fn().mockResolvedValue('token');
270
+ const reranker = new RagApiReranker({
271
+ baseUrl,
272
+ tokenSupplier,
273
+ profile: 'custom-profile',
274
+ logger: mockLogger,
275
+ });
276
+ jest.spyOn(mockLogger, 'debug').mockImplementation(() => mockLogger);
277
+ const postSpy = jest.spyOn(axios, 'post').mockResolvedValueOnce({
278
+ data: { results: [{ id: '0', index: 0, score: 0.9 }] },
279
+ });
280
+
281
+ await reranker.rerank('query', ['document1'], 1);
282
+
283
+ const [, requestBody] = postSpy.mock.calls[0];
284
+ expect((requestBody as t.RagApiRerankRequestBody).profile).toBe(
285
+ 'custom-profile'
286
+ );
287
+ });
288
+
289
+ it('should bound the rerank request with the default timeout', async () => {
290
+ const tokenSupplier = jest.fn().mockResolvedValue('token');
291
+ const reranker = new RagApiReranker({
292
+ baseUrl,
293
+ tokenSupplier,
294
+ logger: mockLogger,
295
+ });
296
+ jest.spyOn(mockLogger, 'debug').mockImplementation(() => mockLogger);
297
+ const postSpy = jest.spyOn(axios, 'post').mockResolvedValueOnce({
298
+ data: { results: [{ id: '0', index: 0, score: 0.9 }] },
299
+ });
300
+
301
+ await reranker.rerank('query', ['document1'], 1);
302
+
303
+ expect(postSpy).toHaveBeenCalledWith(
304
+ expect.any(String),
305
+ expect.any(Object),
306
+ expect.objectContaining({ timeout: 10000 })
307
+ );
308
+ });
309
+
310
+ it('should bound the rerank request with a custom timeout', async () => {
311
+ const tokenSupplier = jest.fn().mockResolvedValue('token');
312
+ const reranker = new RagApiReranker({
313
+ baseUrl,
314
+ tokenSupplier,
315
+ timeout: 3000,
316
+ logger: mockLogger,
317
+ });
318
+ jest.spyOn(mockLogger, 'debug').mockImplementation(() => mockLogger);
319
+ const postSpy = jest.spyOn(axios, 'post').mockResolvedValueOnce({
320
+ data: { results: [{ id: '0', index: 0, score: 0.9 }] },
321
+ });
322
+
323
+ await reranker.rerank('query', ['document1'], 1);
324
+
325
+ expect(postSpy).toHaveBeenCalledWith(
326
+ expect.any(String),
327
+ expect.any(Object),
328
+ expect.objectContaining({ timeout: 3000 })
329
+ );
330
+ });
331
+
332
+ it('should map results back onto the original document text by index', async () => {
333
+ const tokenSupplier = jest.fn().mockResolvedValue('token');
334
+ const reranker = new RagApiReranker({
335
+ baseUrl,
336
+ tokenSupplier,
337
+ logger: mockLogger,
338
+ });
339
+ jest.spyOn(mockLogger, 'debug').mockImplementation(() => mockLogger);
340
+ jest.spyOn(axios, 'post').mockResolvedValueOnce({
341
+ data: {
342
+ results: [
343
+ { id: '2', index: 2, score: 0.95 },
344
+ { id: '0', index: 0, score: 0.4 },
345
+ ],
346
+ },
347
+ });
348
+
349
+ const result = await reranker.rerank(
350
+ 'query',
351
+ ['document1', 'document2', 'document3'],
352
+ 2
353
+ );
354
+
355
+ expect(result).toEqual([
356
+ { text: 'document3', score: 0.95 },
357
+ { text: 'document1', score: 0.4 },
358
+ ]);
359
+ });
360
+
361
+ it('should order results deterministically by index on tied scores, regardless of response order', async () => {
362
+ const tokenSupplier = jest.fn().mockResolvedValue('token');
363
+ const reranker = new RagApiReranker({
364
+ baseUrl,
365
+ tokenSupplier,
366
+ logger: mockLogger,
367
+ });
368
+ jest.spyOn(mockLogger, 'debug').mockImplementation(() => mockLogger);
369
+ jest.spyOn(axios, 'post').mockResolvedValueOnce({
370
+ data: {
371
+ // Deliberately out of index order, all tied at the same score.
372
+ results: [
373
+ { id: '2', index: 2, score: 0.5 },
374
+ { id: '0', index: 0, score: 0.5 },
375
+ { id: '1', index: 1, score: 0.5 },
376
+ ],
377
+ },
378
+ });
379
+
380
+ const result = await reranker.rerank(
381
+ 'query',
382
+ ['document1', 'document2', 'document3'],
383
+ 3
384
+ );
385
+
386
+ expect(result).toEqual([
387
+ { text: 'document1', score: 0.5 },
388
+ { text: 'document2', score: 0.5 },
389
+ { text: 'document3', score: 0.5 },
390
+ ]);
391
+ });
392
+
393
+ it('should truncate candidates beyond the 50-candidate contract limit with a debug log', async () => {
394
+ const tokenSupplier = jest.fn().mockResolvedValue('token');
395
+ const reranker = new RagApiReranker({
396
+ baseUrl,
397
+ tokenSupplier,
398
+ logger: mockLogger,
399
+ });
400
+ const debugSpy = jest
401
+ .spyOn(mockLogger, 'debug')
402
+ .mockImplementation(() => mockLogger);
403
+ const documents = Array.from({ length: 60 }, (_, i) => `document${i}`);
404
+ const postSpy = jest.spyOn(axios, 'post').mockResolvedValueOnce({
405
+ data: {
406
+ results: Array.from({ length: 25 }, (_, i) => ({
407
+ id: String(i),
408
+ index: i,
409
+ score: 1 - i / 100,
410
+ })),
411
+ },
412
+ });
413
+
414
+ await reranker.rerank('query', documents, 25);
415
+
416
+ const [, requestBody] = postSpy.mock.calls[0];
417
+ expect(
418
+ (requestBody as t.RagApiRerankRequestBody).candidates
419
+ ).toHaveLength(50);
420
+ expect(debugSpy).toHaveBeenCalledWith(
421
+ expect.stringContaining(
422
+ 'accepts at most 50 candidates; truncating 60 to 50'
423
+ )
424
+ );
425
+ });
426
+
427
+ it('should clamp top_n beyond the 25-result contract limit with a debug log', async () => {
428
+ const tokenSupplier = jest.fn().mockResolvedValue('token');
429
+ const reranker = new RagApiReranker({
430
+ baseUrl,
431
+ tokenSupplier,
432
+ logger: mockLogger,
433
+ });
434
+ const debugSpy = jest
435
+ .spyOn(mockLogger, 'debug')
436
+ .mockImplementation(() => mockLogger);
437
+ const documents = Array.from({ length: 30 }, (_, i) => `document${i}`);
438
+ const postSpy = jest.spyOn(axios, 'post').mockResolvedValueOnce({
439
+ data: {
440
+ results: Array.from({ length: 25 }, (_, i) => ({
441
+ id: String(i),
442
+ index: i,
443
+ score: 1 - i / 100,
444
+ })),
445
+ },
446
+ });
447
+
448
+ const result = await reranker.rerank('query', documents, 40);
449
+
450
+ const [, requestBody] = postSpy.mock.calls[0];
451
+ expect((requestBody as t.RagApiRerankRequestBody).top_n).toBe(25);
452
+ expect(result).toHaveLength(25);
453
+ expect(debugSpy).toHaveBeenCalledWith(
454
+ expect.stringContaining('accepts top_n <= 25; clamping 40 to 25')
455
+ );
456
+ });
457
+
458
+ it('should fall back to the candidates original order on a request timeout', async () => {
459
+ const tokenSupplier = jest.fn().mockResolvedValue('token');
460
+ const reranker = new RagApiReranker({
461
+ baseUrl,
462
+ tokenSupplier,
463
+ logger: mockLogger,
464
+ });
465
+ jest.spyOn(mockLogger, 'debug').mockImplementation(() => mockLogger);
466
+ const errorSpy = jest
467
+ .spyOn(mockLogger, 'error')
468
+ .mockImplementation(() => mockLogger);
469
+ const timeoutError = Object.assign(new Error('timeout of 10000ms exceeded'), {
470
+ isAxiosError: true,
471
+ code: 'ECONNABORTED',
472
+ });
473
+ jest.spyOn(axios, 'isAxiosError').mockReturnValue(true);
474
+ jest.spyOn(axios, 'post').mockRejectedValueOnce(timeoutError);
475
+
476
+ const result = await reranker.rerank(
477
+ 'query',
478
+ ['document1', 'document2', 'document3'],
479
+ 3
480
+ );
481
+
482
+ expect(result).toEqual([
483
+ { text: 'document1', score: 0 },
484
+ { text: 'document2', score: 0 },
485
+ { text: 'document3', score: 0 },
486
+ ]);
487
+ expect(errorSpy).toHaveBeenCalledWith(
488
+ 'Error using rag_api reranker',
489
+ expect.objectContaining({ code: 'ECONNABORTED' })
490
+ );
491
+ });
492
+
493
+ it('should fall back to the candidates original order on a non-2xx response', async () => {
494
+ const tokenSupplier = jest.fn().mockResolvedValue('token');
495
+ const reranker = new RagApiReranker({
496
+ baseUrl,
497
+ tokenSupplier,
498
+ logger: mockLogger,
499
+ });
500
+ jest.spyOn(mockLogger, 'debug').mockImplementation(() => mockLogger);
501
+ const errorSpy = jest
502
+ .spyOn(mockLogger, 'error')
503
+ .mockImplementation(() => mockLogger);
504
+ const httpError = Object.assign(new Error('Request failed with status code 500'), {
505
+ isAxiosError: true,
506
+ code: 'ERR_BAD_RESPONSE',
507
+ response: { status: 500, data: { message: 'upstream failed' } },
508
+ });
509
+ jest.spyOn(axios, 'isAxiosError').mockReturnValue(true);
510
+ jest.spyOn(axios, 'post').mockRejectedValueOnce(httpError);
511
+
512
+ const result = await reranker.rerank('query', ['document1'], 1);
513
+
514
+ expect(result).toEqual([{ text: 'document1', score: 0 }]);
515
+ expect(errorSpy).toHaveBeenCalledWith(
516
+ 'Error using rag_api reranker',
517
+ expect.objectContaining({ status: 500 })
518
+ );
519
+
520
+ const metadata = errorSpy.mock.calls.flat()[1];
521
+ expect(JSON.stringify(metadata)).not.toContain('upstream failed');
522
+ });
523
+
524
+ it('should fall back to the candidates original order when the response has no results array', async () => {
525
+ const tokenSupplier = jest.fn().mockResolvedValue('token');
526
+ const reranker = new RagApiReranker({
527
+ baseUrl,
528
+ tokenSupplier,
529
+ logger: mockLogger,
530
+ });
531
+ jest.spyOn(mockLogger, 'debug').mockImplementation(() => mockLogger);
532
+ const warnSpy = jest
533
+ .spyOn(mockLogger, 'warn')
534
+ .mockImplementation(() => mockLogger);
535
+ jest.spyOn(axios, 'post').mockResolvedValueOnce({
536
+ data: { profile: 'fast-v1', model: 'embed-blend-v0' },
537
+ });
538
+
539
+ const result = await reranker.rerank(
540
+ 'query',
541
+ ['document1', 'document2'],
542
+ 2
543
+ );
544
+
545
+ expect(result).toEqual([
546
+ { text: 'document1', score: 0 },
547
+ { text: 'document2', score: 0 },
548
+ ]);
549
+ expect(warnSpy).toHaveBeenCalledWith(
550
+ 'Unexpected response format from rag_api rerank. Using default ranking.'
551
+ );
552
+ });
553
+
554
+ it('should fall back to the candidates original order when results carry out-of-range indices', async () => {
555
+ const tokenSupplier = jest.fn().mockResolvedValue('token');
556
+ const reranker = new RagApiReranker({
557
+ baseUrl,
558
+ tokenSupplier,
559
+ logger: mockLogger,
560
+ });
561
+ jest.spyOn(mockLogger, 'debug').mockImplementation(() => mockLogger);
562
+ const warnSpy = jest
563
+ .spyOn(mockLogger, 'warn')
564
+ .mockImplementation(() => mockLogger);
565
+ jest.spyOn(axios, 'post').mockResolvedValueOnce({
566
+ data: {
567
+ results: [
568
+ { id: '5', index: 5, score: 0.9 },
569
+ { id: '-1', index: -1, score: 0.8 },
570
+ ],
571
+ },
572
+ });
573
+
574
+ const result = await reranker.rerank(
575
+ 'query',
576
+ ['document1', 'document2'],
577
+ 2
578
+ );
579
+
580
+ expect(result).toEqual([
581
+ { text: 'document1', score: 0 },
582
+ { text: 'document2', score: 0 },
583
+ ]);
584
+ expect(warnSpy).toHaveBeenCalledWith(
585
+ 'rag_api rerank response contained no valid results. Using default ranking.'
586
+ );
587
+ });
588
+
589
+ it('should fall back to the candidates original order when the token supplier rejects', async () => {
590
+ const tokenSupplier = jest
591
+ .fn()
592
+ .mockRejectedValue(new Error('token mint failed'));
593
+ const reranker = new RagApiReranker({
594
+ baseUrl,
595
+ tokenSupplier,
596
+ logger: mockLogger,
597
+ });
598
+ jest.spyOn(mockLogger, 'debug').mockImplementation(() => mockLogger);
599
+ jest.spyOn(mockLogger, 'error').mockImplementation(() => mockLogger);
600
+ const postSpy = jest.spyOn(axios, 'post');
601
+
602
+ const result = await reranker.rerank('query', ['document1'], 1);
603
+
604
+ expect(result).toEqual([{ text: 'document1', score: 0 }]);
605
+ expect(postSpy).not.toHaveBeenCalled();
606
+ });
607
+
608
+ it('should fall back to the candidates original order when the token supplier never resolves', async () => {
609
+ const reranker = new RagApiReranker({
610
+ baseUrl,
611
+ tokenSupplier: () => new Promise<string>(() => undefined),
612
+ timeout: 50,
613
+ logger: mockLogger,
614
+ });
615
+ jest.spyOn(mockLogger, 'debug').mockImplementation(() => mockLogger);
616
+ const errorSpy = jest
617
+ .spyOn(mockLogger, 'error')
618
+ .mockImplementation(() => mockLogger);
619
+ const postSpy = jest.spyOn(axios, 'post');
620
+
621
+ const startedAt = Date.now();
622
+ const result = await reranker.rerank(
623
+ 'query',
624
+ ['document1', 'document2'],
625
+ 2
626
+ );
627
+
628
+ expect(result).toEqual([
629
+ { text: 'document1', score: 0 },
630
+ { text: 'document2', score: 0 },
631
+ ]);
632
+ expect(Date.now() - startedAt).toBeLessThan(2000);
633
+ expect(postSpy).not.toHaveBeenCalled();
634
+ expect(errorSpy).toHaveBeenCalledWith(
635
+ 'Error using rag_api reranker',
636
+ expect.objectContaining({
637
+ message: 'rag_api rerank exceeded its 50ms timeout.',
638
+ })
639
+ );
640
+ });
641
+
642
+ it('should abort the token supplier when the deadline fires', async () => {
643
+ let supplierSignal: AbortSignal | undefined;
644
+ const tokenSupplier = jest.fn(
645
+ (signal?: AbortSignal) =>
646
+ new Promise<string>((_resolve, reject) => {
647
+ supplierSignal = signal;
648
+ signal?.addEventListener('abort', () =>
649
+ reject(new Error('token acquisition aborted'))
650
+ );
651
+ })
652
+ );
653
+ const reranker = new RagApiReranker({
654
+ baseUrl,
655
+ tokenSupplier,
656
+ timeout: 50,
657
+ logger: mockLogger,
658
+ });
659
+ jest.spyOn(mockLogger, 'debug').mockImplementation(() => mockLogger);
660
+ jest.spyOn(mockLogger, 'error').mockImplementation(() => mockLogger);
661
+ const postSpy = jest.spyOn(axios, 'post');
662
+
663
+ const result = await reranker.rerank(
664
+ 'query',
665
+ ['document1', 'document2'],
666
+ 2
667
+ );
668
+
669
+ expect(supplierSignal).toBeInstanceOf(AbortSignal);
670
+ expect(supplierSignal?.aborted).toBe(true);
671
+ expect(result).toEqual([
672
+ { text: 'document1', score: 0 },
673
+ { text: 'document2', score: 0 },
674
+ ]);
675
+ expect(postSpy).not.toHaveBeenCalled();
676
+ });
677
+
678
+ it('should not pass a signal to the token supplier when the timeout is disabled', async () => {
679
+ let supplierSignal: AbortSignal | undefined;
680
+ const tokenSupplier = jest.fn((signal?: AbortSignal) => {
681
+ supplierSignal = signal;
682
+ return 'token';
683
+ });
684
+ const reranker = new RagApiReranker({
685
+ baseUrl,
686
+ tokenSupplier,
687
+ timeout: 0,
688
+ logger: mockLogger,
689
+ });
690
+ jest.spyOn(mockLogger, 'debug').mockImplementation(() => mockLogger);
691
+ jest.spyOn(axios, 'post').mockResolvedValueOnce({
692
+ data: { results: [{ id: '0', index: 0, score: 0.9 }] },
693
+ });
694
+
695
+ const result = await reranker.rerank('query', ['document1'], 1);
696
+
697
+ expect(supplierSignal).toBeUndefined();
698
+ expect(result).toEqual([{ text: 'document1', score: 0.9 }]);
699
+ });
700
+
701
+ it('should fall back to the candidates original order when the request never settles', async () => {
702
+ const tokenSupplier = jest.fn().mockResolvedValue('token');
703
+ const reranker = new RagApiReranker({
704
+ baseUrl,
705
+ tokenSupplier,
706
+ timeout: 50,
707
+ logger: mockLogger,
708
+ });
709
+ jest.spyOn(mockLogger, 'debug').mockImplementation(() => mockLogger);
710
+ jest.spyOn(mockLogger, 'error').mockImplementation(() => mockLogger);
711
+ jest
712
+ .spyOn(axios, 'post')
713
+ .mockReturnValueOnce(new Promise(() => undefined));
714
+
715
+ const startedAt = Date.now();
716
+ const result = await reranker.rerank('query', ['document1'], 1);
717
+
718
+ expect(result).toEqual([{ text: 'document1', score: 0 }]);
719
+ expect(Date.now() - startedAt).toBeLessThan(2000);
720
+ expect(tokenSupplier).toHaveBeenCalledTimes(1);
721
+ });
722
+
723
+ it('should fall back to the candidates original order when an index exceeds the submitted candidate count', async () => {
724
+ const tokenSupplier = jest.fn().mockResolvedValue('token');
725
+ const reranker = new RagApiReranker({
726
+ baseUrl,
727
+ tokenSupplier,
728
+ logger: mockLogger,
729
+ });
730
+ jest.spyOn(mockLogger, 'debug').mockImplementation(() => mockLogger);
731
+ const warnSpy = jest
732
+ .spyOn(mockLogger, 'warn')
733
+ .mockImplementation(() => mockLogger);
734
+ const documents = Array.from({ length: 60 }, (_, i) => `document${i}`);
735
+ jest.spyOn(axios, 'post').mockResolvedValueOnce({
736
+ data: {
737
+ results: [
738
+ { id: '0', index: 0, score: 0.9 },
739
+ // Within `documents` but beyond the 50 candidates submitted.
740
+ { id: '55', index: 55, score: 0.8 },
741
+ ],
742
+ },
743
+ });
744
+
745
+ const result = await reranker.rerank('query', documents, 3);
746
+
747
+ expect(result).toEqual([
748
+ { text: 'document0', score: 0 },
749
+ { text: 'document1', score: 0 },
750
+ { text: 'document2', score: 0 },
751
+ ]);
752
+ expect(warnSpy).toHaveBeenCalledWith(
753
+ 'rag_api rerank response contained no valid results. Using default ranking.'
754
+ );
755
+ });
756
+
757
+ it('should reject the whole batch when valid and malformed results are mixed', async () => {
758
+ const tokenSupplier = jest.fn().mockResolvedValue('token');
759
+ const reranker = new RagApiReranker({
760
+ baseUrl,
761
+ tokenSupplier,
762
+ logger: mockLogger,
763
+ });
764
+ jest.spyOn(mockLogger, 'debug').mockImplementation(() => mockLogger);
765
+ const warnSpy = jest
766
+ .spyOn(mockLogger, 'warn')
767
+ .mockImplementation(() => mockLogger);
768
+ jest.spyOn(axios, 'post').mockResolvedValueOnce({
769
+ data: {
770
+ results: [
771
+ { id: '1', index: 1, score: 0.95 },
772
+ { id: '0', index: 0, score: Number.NaN },
773
+ ],
774
+ },
775
+ });
776
+
777
+ const result = await reranker.rerank(
778
+ 'query',
779
+ ['document1', 'document2'],
780
+ 2
781
+ );
782
+
783
+ expect(result).toEqual([
784
+ { text: 'document1', score: 0 },
785
+ { text: 'document2', score: 0 },
786
+ ]);
787
+ expect(warnSpy).toHaveBeenCalledWith(
788
+ 'rag_api rerank response contained no valid results. Using default ranking.'
789
+ );
790
+ });
791
+
792
+ it('should reject the whole batch when a valid index is duplicated', async () => {
793
+ const tokenSupplier = jest.fn().mockResolvedValue('token');
794
+ const reranker = new RagApiReranker({
795
+ baseUrl,
796
+ tokenSupplier,
797
+ logger: mockLogger,
798
+ });
799
+ jest.spyOn(mockLogger, 'debug').mockImplementation(() => mockLogger);
800
+ const warnSpy = jest
801
+ .spyOn(mockLogger, 'warn')
802
+ .mockImplementation(() => mockLogger);
803
+ jest.spyOn(axios, 'post').mockResolvedValueOnce({
804
+ data: {
805
+ results: [
806
+ { id: '0', index: 0, score: 0.95 },
807
+ { id: '0', index: 0, score: 0.75 },
808
+ ],
809
+ },
810
+ });
811
+
812
+ const result = await reranker.rerank(
813
+ 'query',
814
+ ['document1', 'document2'],
815
+ 2
816
+ );
817
+
818
+ expect(result).toEqual([
819
+ { text: 'document1', score: 0 },
820
+ { text: 'document2', score: 0 },
821
+ ]);
822
+ expect(warnSpy).toHaveBeenCalledWith(
823
+ 'rag_api rerank response contained no valid results. Using default ranking.'
824
+ );
825
+ });
826
+
827
+ it('should submit only the first 50 documents as candidates', async () => {
828
+ const tokenSupplier = jest.fn().mockResolvedValue('token');
829
+ const reranker = new RagApiReranker({
830
+ baseUrl,
831
+ tokenSupplier,
832
+ logger: mockLogger,
833
+ });
834
+ jest.spyOn(mockLogger, 'debug').mockImplementation(() => mockLogger);
835
+ const documents = Array.from({ length: 400 }, (_, i) => `document${i}`);
836
+ const postSpy = jest.spyOn(axios, 'post').mockResolvedValueOnce({
837
+ data: { results: [{ id: '0', index: 0, score: 0.9 }] },
838
+ });
839
+
840
+ await reranker.rerank('query', documents, 1);
841
+
842
+ const [, requestBody] = postSpy.mock.calls[0];
843
+ const { candidates } = requestBody as t.RagApiRerankRequestBody;
844
+ expect(candidates).toHaveLength(50);
845
+ expect(candidates[49]).toEqual({
846
+ id: '49',
847
+ text: 'document49',
848
+ base_score: 0,
849
+ });
850
+ });
851
+ });
852
+ });
853
+
854
+ describe('createReranker (rag-api)', () => {
855
+ it('should create a RagApiReranker for rerankerType "rag-api"', () => {
856
+ const tokenSupplier = jest.fn().mockResolvedValue('token');
857
+ const reranker = createReranker({
858
+ rerankerType: 'rag-api',
859
+ ragApiUrl: 'https://rag.example.com',
860
+ ragApiTokenSupplier: tokenSupplier,
861
+ });
862
+
863
+ expect(reranker).toBeInstanceOf(RagApiReranker);
864
+ });
865
+
866
+ it('should pass ragApiProfile and rerankerTimeout through to the rerank request', async () => {
867
+ const tokenSupplier = jest.fn().mockResolvedValue('token');
868
+ const reranker = createReranker({
869
+ rerankerType: 'rag-api',
870
+ ragApiUrl: 'https://rag.example.com',
871
+ ragApiTokenSupplier: tokenSupplier,
872
+ ragApiProfile: 'custom-profile',
873
+ rerankerTimeout: 5000,
874
+ logger: createDefaultLogger(),
875
+ });
876
+ if (!(reranker instanceof RagApiReranker)) {
877
+ throw new Error('Expected createReranker to return a RagApiReranker.');
878
+ }
879
+ const postSpy = jest.spyOn(axios, 'post').mockResolvedValueOnce({
880
+ data: { results: [{ id: '0', index: 0, score: 0.9 }] },
881
+ });
882
+
883
+ await reranker.rerank('query', ['document1'], 1);
884
+
885
+ expect(postSpy).toHaveBeenCalledWith(
886
+ 'https://rag.example.com/v1/rerank',
887
+ expect.objectContaining({ profile: 'custom-profile' }),
888
+ expect.objectContaining({ timeout: 5000 })
889
+ );
890
+ });
891
+ });