@librechat/agents 3.2.57 → 3.2.58

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 (73) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +7 -1
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +2 -2
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/langfuseToolOutputTracing.cjs +4 -0
  6. package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -1
  7. package/dist/cjs/langfuseTraceShaping.cjs +172 -0
  8. package/dist/cjs/langfuseTraceShaping.cjs.map +1 -0
  9. package/dist/cjs/run.cjs +2 -2
  10. package/dist/cjs/run.cjs.map +1 -1
  11. package/dist/cjs/tools/search/keenable-search.cjs +68 -0
  12. package/dist/cjs/tools/search/keenable-search.cjs.map +1 -0
  13. package/dist/cjs/tools/search/rerankers.cjs +28 -11
  14. package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
  15. package/dist/cjs/tools/search/search.cjs +30 -4
  16. package/dist/cjs/tools/search/search.cjs.map +1 -1
  17. package/dist/cjs/tools/search/tool.cjs +14 -6
  18. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  19. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +12 -1
  20. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  21. package/dist/cjs/utils/title.cjs +9 -9
  22. package/dist/cjs/utils/title.cjs.map +1 -1
  23. package/dist/esm/agents/AgentContext.mjs +7 -1
  24. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  25. package/dist/esm/graphs/Graph.mjs +2 -2
  26. package/dist/esm/graphs/Graph.mjs.map +1 -1
  27. package/dist/esm/langfuseToolOutputTracing.mjs +4 -0
  28. package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
  29. package/dist/esm/langfuseTraceShaping.mjs +171 -0
  30. package/dist/esm/langfuseTraceShaping.mjs.map +1 -0
  31. package/dist/esm/run.mjs +2 -2
  32. package/dist/esm/run.mjs.map +1 -1
  33. package/dist/esm/tools/search/keenable-search.mjs +66 -0
  34. package/dist/esm/tools/search/keenable-search.mjs.map +1 -0
  35. package/dist/esm/tools/search/rerankers.mjs +28 -11
  36. package/dist/esm/tools/search/rerankers.mjs.map +1 -1
  37. package/dist/esm/tools/search/search.mjs +30 -4
  38. package/dist/esm/tools/search/search.mjs.map +1 -1
  39. package/dist/esm/tools/search/tool.mjs +14 -6
  40. package/dist/esm/tools/search/tool.mjs.map +1 -1
  41. package/dist/esm/tools/subagent/SubagentExecutor.mjs +12 -1
  42. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  43. package/dist/esm/utils/title.mjs +9 -9
  44. package/dist/esm/utils/title.mjs.map +1 -1
  45. package/dist/types/langfuseTraceShaping.d.ts +20 -0
  46. package/dist/types/tools/search/keenable-search.d.ts +4 -0
  47. package/dist/types/tools/search/rerankers.d.ts +7 -2
  48. package/dist/types/tools/search/types.d.ts +38 -1
  49. package/dist/types/types/graph.d.ts +22 -0
  50. package/package.json +1 -1
  51. package/src/agents/AgentContext.ts +9 -0
  52. package/src/agents/__tests__/AgentContext.test.ts +40 -0
  53. package/src/graphs/Graph.ts +25 -2
  54. package/src/graphs/__tests__/composition.smoke.test.ts +53 -0
  55. package/src/langfuseToolOutputTracing.ts +11 -0
  56. package/src/langfuseTraceShaping.ts +280 -0
  57. package/src/llm/anthropic/inherited-stream-events.spec.ts +6 -3
  58. package/src/run.ts +3 -3
  59. package/src/specs/langfuse-routing.integration.test.ts +1 -1
  60. package/src/specs/langfuse-trace-shaping.test.ts +194 -0
  61. package/src/tools/__tests__/SubagentExecutor.test.ts +44 -0
  62. package/src/tools/__tests__/hitl.test.ts +85 -0
  63. package/src/tools/search/jina-reranker.test.ts +70 -1
  64. package/src/tools/search/keenable-search.ts +98 -0
  65. package/src/tools/search/keenable.test.ts +183 -0
  66. package/src/tools/search/rerankers.ts +41 -4
  67. package/src/tools/search/search.ts +58 -2
  68. package/src/tools/search/source-processing.test.ts +86 -0
  69. package/src/tools/search/tool.ts +29 -4
  70. package/src/tools/search/types.ts +42 -1
  71. package/src/tools/subagent/SubagentExecutor.ts +11 -0
  72. package/src/types/graph.ts +22 -0
  73. package/src/utils/title.ts +9 -9
@@ -0,0 +1,183 @@
1
+ import axios from 'axios';
2
+ import { createSearchAPI } from './search';
3
+ import { createSearchTool } from './tool';
4
+ import { DATE_RANGE } from './schema';
5
+
6
+ jest.mock('axios');
7
+ const mockedAxios = axios as jest.Mocked<typeof axios>;
8
+
9
+ const sampleResponse = {
10
+ data: {
11
+ results: [
12
+ {
13
+ title: 'TypeScript Best Practices 2026',
14
+ url: 'https://example.com/ts',
15
+ description: 'A comprehensive guide to TypeScript.',
16
+ published_at: '2026-01-15T10:30:00Z',
17
+ },
18
+ {
19
+ title: 'Second result',
20
+ url: 'https://example.com/second',
21
+ snippet: 'Snippet fallback when description is absent.',
22
+ },
23
+ ],
24
+ },
25
+ };
26
+
27
+ describe('Keenable search API', () => {
28
+ beforeEach(() => {
29
+ jest.clearAllMocks();
30
+ delete process.env.KEENABLE_API_KEY;
31
+ delete process.env.KEENABLE_API_URL;
32
+ });
33
+
34
+ it('returns an error for empty queries without calling the API', async () => {
35
+ const searchAPI = createSearchAPI({ searchProvider: 'keenable' });
36
+ const result = await searchAPI.getSources({ query: ' ' });
37
+
38
+ expect(result).toEqual({ success: false, error: 'Query cannot be empty' });
39
+ expect(mockedAxios.post).not.toHaveBeenCalled();
40
+ });
41
+
42
+ it('hits the public endpoint and omits the API key header when keyless', async () => {
43
+ mockedAxios.post.mockResolvedValueOnce(sampleResponse);
44
+
45
+ const searchAPI = createSearchAPI({ searchProvider: 'keenable' });
46
+ const result = await searchAPI.getSources({ query: 'typescript' });
47
+
48
+ expect(mockedAxios.post).toHaveBeenCalledWith(
49
+ 'https://api.keenable.ai/v1/search/public',
50
+ { query: 'typescript' },
51
+ expect.objectContaining({
52
+ headers: expect.objectContaining({ 'X-Keenable-Title': 'LibreChat' }),
53
+ })
54
+ );
55
+ const headers = mockedAxios.post.mock.calls[0][2]?.headers as Record<
56
+ string,
57
+ string
58
+ >;
59
+ expect(headers['X-API-Key']).toBeUndefined();
60
+ expect(result.success).toBe(true);
61
+ });
62
+
63
+ it('hits the authenticated endpoint and sends the API key when a key is set', async () => {
64
+ mockedAxios.post.mockResolvedValueOnce(sampleResponse);
65
+
66
+ const searchAPI = createSearchAPI({
67
+ searchProvider: 'keenable',
68
+ keenableApiKey: 'secret-key',
69
+ });
70
+ await searchAPI.getSources({ query: 'typescript' });
71
+
72
+ expect(mockedAxios.post).toHaveBeenCalledWith(
73
+ 'https://api.keenable.ai/v1/search',
74
+ { query: 'typescript' },
75
+ expect.objectContaining({
76
+ headers: expect.objectContaining({ 'X-API-Key': 'secret-key' }),
77
+ })
78
+ );
79
+ });
80
+
81
+ it('maps results into organic sources (description and snippet fallback)', async () => {
82
+ mockedAxios.post.mockResolvedValueOnce(sampleResponse);
83
+
84
+ const searchAPI = createSearchAPI({ searchProvider: 'keenable' });
85
+ const result = await searchAPI.getSources({ query: 'typescript' });
86
+
87
+ expect(result.data?.organic).toEqual([
88
+ {
89
+ title: 'TypeScript Best Practices 2026',
90
+ link: 'https://example.com/ts',
91
+ snippet: 'A comprehensive guide to TypeScript.',
92
+ date: '2026-01-15T10:30:00Z',
93
+ },
94
+ {
95
+ title: 'Second result',
96
+ link: 'https://example.com/second',
97
+ snippet: 'Snippet fallback when description is absent.',
98
+ date: undefined,
99
+ },
100
+ ]);
101
+ });
102
+
103
+ it('applies the site filter and limits results client-side', async () => {
104
+ mockedAxios.post.mockResolvedValueOnce({
105
+ data: {
106
+ results: [{ url: '1' }, { url: '2' }, { url: '3' }],
107
+ },
108
+ });
109
+
110
+ const searchAPI = createSearchAPI({
111
+ searchProvider: 'keenable',
112
+ keenableSearchOptions: { site: 'github.com', maxResults: 2 },
113
+ });
114
+ const result = await searchAPI.getSources({ query: 'typescript' });
115
+
116
+ expect(mockedAxios.post).toHaveBeenCalledWith(
117
+ expect.any(String),
118
+ { query: 'typescript', site: 'github.com' },
119
+ expect.any(Object)
120
+ );
121
+ expect(result.data?.organic).toHaveLength(2);
122
+ });
123
+
124
+ it('maps date ranges to Keenable published filters', async () => {
125
+ mockedAxios.post.mockResolvedValueOnce(sampleResponse);
126
+
127
+ const searchAPI = createSearchAPI({ searchProvider: 'keenable' });
128
+ await searchAPI.getSources({
129
+ query: 'typescript',
130
+ date: DATE_RANGE.PAST_WEEK,
131
+ });
132
+
133
+ expect(mockedAxios.post).toHaveBeenCalledWith(
134
+ expect.any(String),
135
+ { query: 'typescript', published_after: '7d' },
136
+ expect.any(Object)
137
+ );
138
+ });
139
+
140
+ it('surfaces request failures as a structured error', async () => {
141
+ mockedAxios.post.mockRejectedValueOnce(new Error('Network error'));
142
+
143
+ const searchAPI = createSearchAPI({ searchProvider: 'keenable' });
144
+ const result = await searchAPI.getSources({ query: 'typescript' });
145
+
146
+ expect(result.success).toBe(false);
147
+ expect(result.error).toBe('Keenable API request failed: Network error');
148
+ });
149
+ });
150
+
151
+ describe('Keenable capability gating', () => {
152
+ beforeEach(() => {
153
+ jest.clearAllMocks();
154
+ delete process.env.KEENABLE_API_KEY;
155
+ delete process.env.KEENABLE_API_URL;
156
+ });
157
+
158
+ it('skips image/news/video sub-searches (organic-only provider)', async () => {
159
+ mockedAxios.post.mockResolvedValueOnce(sampleResponse).mockResolvedValue({
160
+ data: { success: true, data: { markdown: '# A', html: '<p>a</p>' } },
161
+ });
162
+
163
+ const searchTool = createSearchTool({
164
+ searchProvider: 'keenable',
165
+ scraperProvider: 'firecrawl',
166
+ firecrawlApiKey: 'k',
167
+ topResults: 1,
168
+ rerankerType: 'none',
169
+ });
170
+
171
+ await searchTool.invoke({
172
+ query: 'typescript',
173
+ images: true,
174
+ news: true,
175
+ videos: true,
176
+ });
177
+
178
+ const searchCalls = mockedAxios.post.mock.calls.filter(([url]) =>
179
+ (url as string).includes('keenable')
180
+ );
181
+ expect(searchCalls).toHaveLength(1);
182
+ });
183
+ });
@@ -4,6 +4,11 @@ import { createDefaultLogger, formatErrorForLog } from './utils';
4
4
 
5
5
  const DEFAULT_JINA_API_URL = 'https://api.jina.ai/v1/rerank';
6
6
 
7
+ /** Every other network call in the search pipeline is bounded (scrapers,
8
+ * search providers); rerank requests must be too, or a hung rerank API
9
+ * stalls the whole tool. */
10
+ const DEFAULT_RERANKER_TIMEOUT = 10000;
11
+
7
12
  const getDefaultJinaApiUrl = (): string =>
8
13
  process.env.JINA_API_URL != null && process.env.JINA_API_URL !== ''
9
14
  ? process.env.JINA_API_URL
@@ -36,19 +41,23 @@ export abstract class BaseReranker {
36
41
 
37
42
  export class JinaReranker extends BaseReranker {
38
43
  private apiUrl: string;
44
+ private timeout: number;
39
45
 
40
46
  constructor({
41
47
  apiKey = process.env.JINA_API_KEY,
42
48
  apiUrl = getDefaultJinaApiUrl(),
49
+ timeout = DEFAULT_RERANKER_TIMEOUT,
43
50
  logger,
44
51
  }: {
45
52
  apiKey?: string;
46
53
  apiUrl?: string;
54
+ timeout?: number;
47
55
  logger?: t.Logger;
48
56
  }) {
49
57
  super(logger);
50
58
  this.apiKey = apiKey;
51
59
  this.apiUrl = apiUrl;
60
+ this.timeout = timeout;
52
61
  }
53
62
 
54
63
  async rerank(
@@ -56,7 +65,9 @@ export class JinaReranker extends BaseReranker {
56
65
  documents: string[],
57
66
  topK: number = 5
58
67
  ): Promise<t.Highlight[]> {
59
- this.logger.debug(`Reranking ${documents.length} chunks with Jina using API URL: ${this.apiUrl}`);
68
+ this.logger.debug(
69
+ `Reranking ${documents.length} chunks with Jina using API URL: ${this.apiUrl}`
70
+ );
60
71
 
61
72
  try {
62
73
  if (this.apiKey == null || this.apiKey === '') {
@@ -80,6 +91,7 @@ export class JinaReranker extends BaseReranker {
80
91
  'Content-Type': 'application/json',
81
92
  Authorization: `Bearer ${this.apiKey}`,
82
93
  },
94
+ timeout: this.timeout,
83
95
  }
84
96
  );
85
97
 
@@ -122,15 +134,20 @@ export class JinaReranker extends BaseReranker {
122
134
  }
123
135
 
124
136
  export class CohereReranker extends BaseReranker {
137
+ private timeout: number;
138
+
125
139
  constructor({
126
140
  apiKey = process.env.COHERE_API_KEY,
141
+ timeout = DEFAULT_RERANKER_TIMEOUT,
127
142
  logger,
128
143
  }: {
129
144
  apiKey?: string;
145
+ timeout?: number;
130
146
  logger?: t.Logger;
131
147
  }) {
132
148
  super(logger);
133
149
  this.apiKey = apiKey;
150
+ this.timeout = timeout;
134
151
  }
135
152
 
136
153
  async rerank(
@@ -161,6 +178,7 @@ export class CohereReranker extends BaseReranker {
161
178
  'Content-Type': 'application/json',
162
179
  Authorization: `Bearer ${this.apiKey}`,
163
180
  },
181
+ timeout: this.timeout,
164
182
  }
165
183
  );
166
184
 
@@ -218,19 +236,33 @@ export const createReranker = (config: {
218
236
  jinaApiKey?: string;
219
237
  jinaApiUrl?: string;
220
238
  cohereApiKey?: string;
239
+ rerankerTimeout?: number;
221
240
  logger?: t.Logger;
222
241
  }): BaseReranker | undefined => {
223
- const { rerankerType, jinaApiKey, jinaApiUrl, cohereApiKey, logger } = config;
242
+ const {
243
+ rerankerType,
244
+ jinaApiKey,
245
+ jinaApiUrl,
246
+ cohereApiKey,
247
+ rerankerTimeout,
248
+ logger,
249
+ } = config;
224
250
 
225
251
  // Create a default logger if none is provided
226
252
  const defaultLogger = logger || createDefaultLogger();
227
253
 
228
254
  switch (rerankerType.toLowerCase()) {
229
255
  case 'jina':
230
- return new JinaReranker({ apiKey: jinaApiKey, apiUrl: jinaApiUrl, logger: defaultLogger });
256
+ return new JinaReranker({
257
+ apiKey: jinaApiKey,
258
+ apiUrl: jinaApiUrl,
259
+ timeout: rerankerTimeout,
260
+ logger: defaultLogger,
261
+ });
231
262
  case 'cohere':
232
263
  return new CohereReranker({
233
264
  apiKey: cohereApiKey,
265
+ timeout: rerankerTimeout,
234
266
  logger: defaultLogger,
235
267
  });
236
268
  case 'infinity':
@@ -242,7 +274,12 @@ export const createReranker = (config: {
242
274
  defaultLogger.warn(
243
275
  `Unknown reranker type: ${rerankerType}. Defaulting to InfinityReranker.`
244
276
  );
245
- return new JinaReranker({ apiKey: jinaApiKey, apiUrl: jinaApiUrl, logger: defaultLogger });
277
+ return new JinaReranker({
278
+ apiKey: jinaApiKey,
279
+ apiUrl: jinaApiUrl,
280
+ timeout: rerankerTimeout,
281
+ logger: defaultLogger,
282
+ });
246
283
  }
247
284
  };
248
285
 
@@ -2,6 +2,7 @@ import axios from 'axios';
2
2
  import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
3
3
  import type * as t from './types';
4
4
  import { getAttribution, createDefaultLogger } from './utils';
5
+ import { createKeenableAPI } from './keenable-search';
5
6
  import { createTavilyAPI } from './tavily-search';
6
7
  import { BaseReranker } from './rerankers';
7
8
 
@@ -67,6 +68,43 @@ const chunker = {
67
68
  };
68
69
 
69
70
  const DEFAULT_MAX_CONTENT_LENGTH = 50000;
71
+ const DEFAULT_CHUNK_SIZE = 150;
72
+ const DEFAULT_CHUNK_OVERLAP = 50;
73
+
74
+ /** Resolves reranker chunking from config, the `SEARCH_CHUNK_SIZE` /
75
+ * `SEARCH_CHUNK_OVERLAP` env vars, or the defaults (150 / 50 chars). The
76
+ * overlap is clamped below the chunk size — `RecursiveCharacterTextSplitter`
77
+ * throws when overlap >= size. */
78
+ function resolveChunkOptions(
79
+ chunkSize?: number,
80
+ chunkOverlap?: number
81
+ ): { chunkSize: number; chunkOverlap: number } {
82
+ const resolve = (
83
+ configValue: number | undefined,
84
+ envVar: string,
85
+ fallback: number
86
+ ): number => {
87
+ if (configValue != null && configValue > 0) {
88
+ return configValue;
89
+ }
90
+ const envValue = Number(process.env[envVar]);
91
+ if (Number.isFinite(envValue) && envValue > 0) {
92
+ return envValue;
93
+ }
94
+ return fallback;
95
+ };
96
+
97
+ const size = resolve(chunkSize, 'SEARCH_CHUNK_SIZE', DEFAULT_CHUNK_SIZE);
98
+ let overlap = resolve(
99
+ chunkOverlap,
100
+ 'SEARCH_CHUNK_OVERLAP',
101
+ DEFAULT_CHUNK_OVERLAP
102
+ );
103
+ if (overlap >= size) {
104
+ overlap = Math.floor(size / 3);
105
+ }
106
+ return { chunkSize: size, chunkOverlap: overlap };
107
+ }
70
108
 
71
109
  /** Resolves the per-source scraped content cap from config, the
72
110
  * `SEARCH_MAX_CONTENT_LENGTH` env var, or the default (50,000 chars) */
@@ -103,6 +141,7 @@ const getHighlights = async ({
103
141
  reranker,
104
142
  topResults = 5,
105
143
  maxContentLength = DEFAULT_MAX_CONTENT_LENGTH,
144
+ chunkOptions,
106
145
  logger,
107
146
  }: {
108
147
  content: string;
@@ -110,6 +149,7 @@ const getHighlights = async ({
110
149
  reranker?: BaseReranker;
111
150
  topResults?: number;
112
151
  maxContentLength?: number;
152
+ chunkOptions?: { chunkSize: number; chunkOverlap: number };
113
153
  logger?: t.Logger;
114
154
  }): Promise<t.Highlight[] | undefined> => {
115
155
  const logger_ = logger || createDefaultLogger();
@@ -125,7 +165,8 @@ const getHighlights = async ({
125
165
 
126
166
  try {
127
167
  const documents = await chunker.splitText(
128
- truncateContent(content, maxContentLength)
168
+ truncateContent(content, maxContentLength),
169
+ chunkOptions
129
170
  );
130
171
  if (Array.isArray(documents)) {
131
172
  return await reranker.rerank(query, documents, topResults);
@@ -445,6 +486,9 @@ export const createSearchAPI = (
445
486
  tavilyApiKey,
446
487
  tavilySearchUrl,
447
488
  tavilySearchOptions,
489
+ keenableApiKey,
490
+ keenableApiUrl,
491
+ keenableSearchOptions,
448
492
  } = config;
449
493
 
450
494
  if (searchProvider.toLowerCase() === 'serper') {
@@ -453,9 +497,15 @@ export const createSearchAPI = (
453
497
  return createSearXNGAPI(searxngInstanceUrl, searxngApiKey);
454
498
  } else if (searchProvider.toLowerCase() === 'tavily') {
455
499
  return createTavilyAPI(tavilyApiKey, tavilySearchUrl, tavilySearchOptions);
500
+ } else if (searchProvider.toLowerCase() === 'keenable') {
501
+ return createKeenableAPI(
502
+ keenableApiKey,
503
+ keenableApiUrl,
504
+ keenableSearchOptions
505
+ );
456
506
  } else {
457
507
  throw new Error(
458
- `Invalid search provider: ${searchProvider}. Must be 'serper', 'searxng', or 'tavily'`
508
+ `Invalid search provider: ${searchProvider}. Must be 'serper', 'searxng', 'tavily', or 'keenable'`
459
509
  );
460
510
  }
461
511
  };
@@ -481,6 +531,10 @@ export const createSourceProcessor = (
481
531
  } = config;
482
532
 
483
533
  const maxContentLength = resolveMaxContentLength(config.maxContentLength);
534
+ const chunkOptions = resolveChunkOptions(
535
+ config.chunkSize,
536
+ config.chunkOverlap
537
+ );
484
538
  const logger_ = logger || createDefaultLogger();
485
539
  const scraper = scraperInstance;
486
540
 
@@ -521,8 +575,10 @@ export const createSourceProcessor = (
521
575
  const highlights = await getHighlights({
522
576
  query,
523
577
  reranker,
578
+ topResults,
524
579
  content: result.content,
525
580
  maxContentLength,
581
+ chunkOptions,
526
582
  logger: logger_,
527
583
  });
528
584
  if (onGetHighlights) {
@@ -14,6 +14,7 @@ const silentLogger = {
14
14
 
15
15
  class RecordingReranker extends BaseReranker {
16
16
  public rerankCalls: string[][] = [];
17
+ public topKCalls: number[] = [];
17
18
 
18
19
  constructor() {
19
20
  super(silentLogger);
@@ -25,6 +26,7 @@ class RecordingReranker extends BaseReranker {
25
26
  topK: number = 5
26
27
  ): Promise<t.Highlight[]> {
27
28
  this.rerankCalls.push(documents);
29
+ this.topKCalls.push(topK);
28
30
  return this.getDefaultRanking(documents, topK);
29
31
  }
30
32
  }
@@ -212,6 +214,90 @@ describe('createSourceProcessor content capping', () => {
212
214
 
213
215
  expect(data.organic?.[0].content?.length).toBe(50000);
214
216
  });
217
+
218
+ test('passes configured topResults through to the reranker', async () => {
219
+ const reranker = new RecordingReranker();
220
+ const scraper = createFakeScraper({ [link]: makeLongContent(3000) });
221
+ const processor = createSourceProcessor(
222
+ { reranker, topResults: 2, logger: silentLogger },
223
+ scraper
224
+ );
225
+
226
+ await processor.processSources({
227
+ ...baseFields,
228
+ news: false,
229
+ numElements: 5,
230
+ result: { success: true, data: { organic: [makeOrganic(link)] } },
231
+ });
232
+
233
+ expect(reranker.topKCalls).toEqual([2]);
234
+ });
235
+ });
236
+
237
+ describe('createSourceProcessor reranker chunking', () => {
238
+ const link = 'https://a.com';
239
+ const baseFields = {
240
+ query: 'test query',
241
+ proMode: true,
242
+ onGetHighlights: undefined,
243
+ };
244
+
245
+ const runWithConfig = async (
246
+ config: Partial<t.ProcessSourcesConfig>
247
+ ): Promise<string[]> => {
248
+ const reranker = new RecordingReranker();
249
+ const scraper = createFakeScraper({ [link]: makeLongContent(10000) });
250
+ const processor = createSourceProcessor(
251
+ { reranker, logger: silentLogger, ...config },
252
+ scraper
253
+ );
254
+
255
+ await processor.processSources({
256
+ ...baseFields,
257
+ news: false,
258
+ numElements: 5,
259
+ result: { success: true, data: { organic: [makeOrganic(link)] } },
260
+ });
261
+
262
+ return reranker.rerankCalls[0] ?? [];
263
+ };
264
+
265
+ test('configured chunkSize produces fewer, larger chunks', async () => {
266
+ const defaultDocs = await runWithConfig({});
267
+ const largeDocs = await runWithConfig({
268
+ chunkSize: 500,
269
+ chunkOverlap: 100,
270
+ });
271
+
272
+ expect(defaultDocs.length).toBeGreaterThan(0);
273
+ expect(largeDocs.length).toBeGreaterThan(0);
274
+ expect(largeDocs.length).toBeLessThan(defaultDocs.length);
275
+ expect(Math.max(...defaultDocs.map((d) => d.length))).toBeLessThanOrEqual(
276
+ 150
277
+ );
278
+ expect(Math.max(...largeDocs.map((d) => d.length))).toBeLessThanOrEqual(
279
+ 500
280
+ );
281
+ });
282
+
283
+ test('respects SEARCH_CHUNK_SIZE env vars when config is not set', async () => {
284
+ process.env.SEARCH_CHUNK_SIZE = '500';
285
+ process.env.SEARCH_CHUNK_OVERLAP = '100';
286
+ try {
287
+ const docs = await runWithConfig({});
288
+ expect(docs.length).toBeGreaterThan(0);
289
+ expect(Math.max(...docs.map((d) => d.length))).toBeLessThanOrEqual(500);
290
+ } finally {
291
+ delete process.env.SEARCH_CHUNK_SIZE;
292
+ delete process.env.SEARCH_CHUNK_OVERLAP;
293
+ }
294
+ });
295
+
296
+ test('clamps overlap below chunk size instead of throwing', async () => {
297
+ const docs = await runWithConfig({ chunkSize: 200, chunkOverlap: 300 });
298
+ expect(docs.length).toBeGreaterThan(0);
299
+ expect(Math.max(...docs.map((d) => d.length))).toBeLessThanOrEqual(200);
300
+ });
215
301
  });
216
302
 
217
303
  describe('createSourceProcessor topStories capping', () => {
@@ -197,13 +197,17 @@ export async function executeParallelSearches({
197
197
  function createSearchProcessor({
198
198
  searchAPI,
199
199
  safeSearch,
200
+ supportsImages,
200
201
  supportsVideos,
202
+ supportsNews,
201
203
  sourceProcessor,
202
204
  onGetHighlights,
203
205
  logger,
204
206
  }: {
205
207
  safeSearch: t.SearchToolConfig['safeSearch'];
208
+ supportsImages: boolean;
206
209
  supportsVideos: boolean;
210
+ supportsNews: boolean;
207
211
  searchAPI: ReturnType<typeof createSearchAPI>;
208
212
  sourceProcessor: ReturnType<typeof createSourceProcessor>;
209
213
  onGetHighlights: t.SearchToolConfig['onGetHighlights'];
@@ -238,9 +242,9 @@ function createSearchProcessor({
238
242
  date,
239
243
  country,
240
244
  safeSearch,
241
- images,
245
+ images: supportsImages && images,
242
246
  videos: supportsVideos && videos,
243
- news,
247
+ news: supportsNews && news,
244
248
  logger,
245
249
  });
246
250
 
@@ -315,7 +319,11 @@ function createTool({
315
319
  }),
316
320
  });
317
321
  const turn = runnableConfig.toolCall?.turn ?? 0;
318
- const { output, references } = formatResultsForLLM(turn, searchResult, maxOutputChars);
322
+ const { output, references } = formatResultsForLLM(
323
+ turn,
324
+ searchResult,
325
+ maxOutputChars
326
+ );
319
327
  const data: t.SearchResultData = { turn, ...searchResult, references };
320
328
  return [output, { [Constants.WEB_SEARCH]: data }];
321
329
  },
@@ -358,9 +366,15 @@ export const createSearchTool = (
358
366
  tavilySearchUrl,
359
367
  tavilyExtractUrl,
360
368
  tavilySearchOptions,
369
+ keenableApiKey,
370
+ keenableApiUrl,
371
+ keenableSearchOptions,
361
372
  rerankerType = 'cohere',
373
+ rerankerTimeout,
362
374
  topResults = 5,
363
375
  maxContentLength,
376
+ chunkSize,
377
+ chunkOverlap,
364
378
  maxOutputChars,
365
379
  strategies = ['no_extraction'],
366
380
  filterContent = true,
@@ -415,6 +429,9 @@ export const createSearchTool = (
415
429
  tavilyApiKey,
416
430
  tavilySearchUrl,
417
431
  tavilySearchOptions: effectiveTavilySearchOptions,
432
+ keenableApiKey,
433
+ keenableApiUrl,
434
+ keenableSearchOptions,
418
435
  });
419
436
 
420
437
  /** Create scraper based on scraperProvider */
@@ -455,6 +472,7 @@ export const createSearchTool = (
455
472
  jinaApiKey,
456
473
  jinaApiUrl,
457
474
  cohereApiKey,
475
+ rerankerTimeout,
458
476
  logger,
459
477
  });
460
478
 
@@ -467,6 +485,8 @@ export const createSearchTool = (
467
485
  reranker: selectedReranker,
468
486
  topResults,
469
487
  maxContentLength,
488
+ chunkSize,
489
+ chunkOverlap,
470
490
  strategies,
471
491
  filterContent,
472
492
  logger,
@@ -477,7 +497,12 @@ export const createSearchTool = (
477
497
  const search = createSearchProcessor({
478
498
  searchAPI,
479
499
  safeSearch,
480
- supportsVideos: searchProvider !== 'tavily',
500
+ // Keenable is organic-only: its API ignores `type`, so image/news
501
+ // sub-searches would spend rate limit and merge nothing.
502
+ supportsImages: searchProvider !== 'keenable',
503
+ supportsVideos:
504
+ searchProvider !== 'tavily' && searchProvider !== 'keenable',
505
+ supportsNews: searchProvider !== 'keenable',
481
506
  sourceProcessor,
482
507
  onGetHighlights,
483
508
  logger,