@librechat/agents 3.2.56 → 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 (92) 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 +7 -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 +6 -2
  10. package/dist/cjs/run.cjs.map +1 -1
  11. package/dist/cjs/stream.cjs +3 -2
  12. package/dist/cjs/stream.cjs.map +1 -1
  13. package/dist/cjs/tools/ToolNode.cjs +19 -5
  14. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  15. package/dist/cjs/tools/search/keenable-search.cjs +68 -0
  16. package/dist/cjs/tools/search/keenable-search.cjs.map +1 -0
  17. package/dist/cjs/tools/search/rerankers.cjs +28 -11
  18. package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
  19. package/dist/cjs/tools/search/search.cjs +30 -4
  20. package/dist/cjs/tools/search/search.cjs.map +1 -1
  21. package/dist/cjs/tools/search/tool.cjs +14 -6
  22. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  23. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +12 -1
  24. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  25. package/dist/cjs/utils/title.cjs +9 -9
  26. package/dist/cjs/utils/title.cjs.map +1 -1
  27. package/dist/esm/agents/AgentContext.mjs +7 -1
  28. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  29. package/dist/esm/graphs/Graph.mjs +7 -2
  30. package/dist/esm/graphs/Graph.mjs.map +1 -1
  31. package/dist/esm/langfuseToolOutputTracing.mjs +4 -0
  32. package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
  33. package/dist/esm/langfuseTraceShaping.mjs +171 -0
  34. package/dist/esm/langfuseTraceShaping.mjs.map +1 -0
  35. package/dist/esm/run.mjs +6 -2
  36. package/dist/esm/run.mjs.map +1 -1
  37. package/dist/esm/stream.mjs +3 -2
  38. package/dist/esm/stream.mjs.map +1 -1
  39. package/dist/esm/tools/ToolNode.mjs +19 -5
  40. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  41. package/dist/esm/tools/search/keenable-search.mjs +66 -0
  42. package/dist/esm/tools/search/keenable-search.mjs.map +1 -0
  43. package/dist/esm/tools/search/rerankers.mjs +28 -11
  44. package/dist/esm/tools/search/rerankers.mjs.map +1 -1
  45. package/dist/esm/tools/search/search.mjs +30 -4
  46. package/dist/esm/tools/search/search.mjs.map +1 -1
  47. package/dist/esm/tools/search/tool.mjs +14 -6
  48. package/dist/esm/tools/search/tool.mjs.map +1 -1
  49. package/dist/esm/tools/subagent/SubagentExecutor.mjs +12 -1
  50. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  51. package/dist/esm/utils/title.mjs +9 -9
  52. package/dist/esm/utils/title.mjs.map +1 -1
  53. package/dist/types/graphs/Graph.d.ts +1 -0
  54. package/dist/types/langfuseTraceShaping.d.ts +20 -0
  55. package/dist/types/run.d.ts +1 -0
  56. package/dist/types/tools/ToolNode.d.ts +11 -1
  57. package/dist/types/tools/search/keenable-search.d.ts +4 -0
  58. package/dist/types/tools/search/rerankers.d.ts +7 -2
  59. package/dist/types/tools/search/types.d.ts +38 -1
  60. package/dist/types/types/graph.d.ts +22 -0
  61. package/dist/types/types/run.d.ts +9 -0
  62. package/dist/types/types/tools.d.ts +6 -0
  63. package/package.json +1 -1
  64. package/src/__tests__/stream.eagerEventExecution.test.ts +44 -0
  65. package/src/agents/AgentContext.ts +9 -0
  66. package/src/agents/__tests__/AgentContext.test.ts +40 -0
  67. package/src/graphs/Graph.ts +31 -3
  68. package/src/graphs/__tests__/composition.smoke.test.ts +53 -0
  69. package/src/langfuseToolOutputTracing.ts +11 -0
  70. package/src/langfuseTraceShaping.ts +280 -0
  71. package/src/llm/anthropic/inherited-stream-events.spec.ts +6 -3
  72. package/src/run.ts +7 -3
  73. package/src/specs/langfuse-routing.integration.test.ts +1 -1
  74. package/src/specs/langfuse-trace-shaping.test.ts +194 -0
  75. package/src/stream.ts +10 -2
  76. package/src/tools/ToolNode.ts +28 -4
  77. package/src/tools/__tests__/SubagentExecutor.test.ts +44 -0
  78. package/src/tools/__tests__/ToolNode.session.test.ts +160 -0
  79. package/src/tools/__tests__/hitl.test.ts +85 -0
  80. package/src/tools/search/jina-reranker.test.ts +70 -1
  81. package/src/tools/search/keenable-search.ts +98 -0
  82. package/src/tools/search/keenable.test.ts +183 -0
  83. package/src/tools/search/rerankers.ts +41 -4
  84. package/src/tools/search/search.ts +58 -2
  85. package/src/tools/search/source-processing.test.ts +86 -0
  86. package/src/tools/search/tool.ts +29 -4
  87. package/src/tools/search/types.ts +42 -1
  88. package/src/tools/subagent/SubagentExecutor.ts +11 -0
  89. package/src/types/graph.ts +22 -0
  90. package/src/types/run.ts +9 -0
  91. package/src/types/tools.ts +6 -0
  92. package/src/utils/title.ts +9 -9
@@ -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,
@@ -3,7 +3,7 @@ import type { Logger as WinstonLogger } from 'winston';
3
3
  import type { BaseReranker } from './rerankers';
4
4
  import { DATE_RANGE } from './schema';
5
5
 
6
- export type SearchProvider = 'serper' | 'searxng' | 'tavily';
6
+ export type SearchProvider = 'serper' | 'searxng' | 'tavily' | 'keenable';
7
7
  export type ScraperProvider = 'firecrawl' | 'serper' | 'tavily';
8
8
  export type RerankerType = 'infinity' | 'jina' | 'cohere' | 'none';
9
9
 
@@ -115,6 +115,36 @@ export interface SearchConfig {
115
115
  tavilySearchUrl?: string;
116
116
  tavilyExtractUrl?: string;
117
117
  tavilySearchOptions?: TavilySearchOptions;
118
+ keenableApiKey?: string;
119
+ keenableApiUrl?: string;
120
+ keenableSearchOptions?: KeenableSearchOptions;
121
+ }
122
+
123
+ export interface KeenableSearchOptions {
124
+ maxResults?: number;
125
+ /** Restrict results to a single domain, e.g. "github.com". */
126
+ site?: string;
127
+ /** Sent as the X-Keenable-Title attribution header. Defaults to "LibreChat". */
128
+ attributionTitle?: string;
129
+ timeout?: number;
130
+ }
131
+
132
+ export interface KeenableSearchPayload {
133
+ query: string;
134
+ site?: string;
135
+ published_after?: string;
136
+ }
137
+
138
+ export interface KeenableSearchResult {
139
+ title?: string;
140
+ url?: string;
141
+ description?: string;
142
+ snippet?: string;
143
+ published_at?: string;
144
+ }
145
+
146
+ export interface KeenableSearchResponse {
147
+ results?: KeenableSearchResult[];
118
148
  }
119
149
 
120
150
  export type References = {
@@ -137,6 +167,15 @@ export interface ProcessSourcesConfig {
137
167
  * chunker/reranker. Defaults to 50,000; also configurable via the
138
168
  * `SEARCH_MAX_CONTENT_LENGTH` env var. */
139
169
  maxContentLength?: number;
170
+ /** Chunk size (chars) for splitting scraped content before reranking.
171
+ * Defaults to 150; also configurable via the `SEARCH_CHUNK_SIZE` env var.
172
+ * Larger chunks send fewer documents to the reranker (lower cost/latency);
173
+ * highlights are expanded ±300-450 chars around each hit either way. */
174
+ chunkSize?: number;
175
+ /** Overlap (chars) between adjacent chunks. Defaults to 50; also
176
+ * configurable via the `SEARCH_CHUNK_OVERLAP` env var. Clamped below
177
+ * `chunkSize`. */
178
+ chunkOverlap?: number;
140
179
  strategies?: string[];
141
180
  filterContent?: boolean;
142
181
  reranker?: BaseReranker;
@@ -231,6 +270,8 @@ export interface SearchToolConfig
231
270
  jinaApiUrl?: string;
232
271
  cohereApiKey?: string;
233
272
  rerankerType?: RerankerType;
273
+ /** Timeout (ms) for rerank API requests. Defaults to 10,000. */
274
+ rerankerTimeout?: number;
234
275
  scraperProvider?: ScraperProvider;
235
276
  scraperTimeout?: number;
236
277
  serperScraperOptions?: SerperScraperConfig;
@@ -1422,6 +1422,17 @@ export function buildChildInputs(
1422
1422
  */
1423
1423
  initialSummary: undefined,
1424
1424
  discoveredTools: undefined,
1425
+ /**
1426
+ * Host-supplied direct tools are scrubbed from INHERITED configs only.
1427
+ * A self-spawn config's `agentInputs` is a shallow spread of the parent's
1428
+ * `_sourceInputs`, so without this a parent-scoped graph tool (e.g. an
1429
+ * interrupt-raising ask_user_question, which needs the parent's
1430
+ * checkpointer — child graphs compile without one) would silently leak
1431
+ * into the child and deterministically throw `No checkpointer set`. An
1432
+ * EXPLICIT child config that lists its own `graphTools` is a deliberate
1433
+ * host choice and keeps them (Codex #289 P2).
1434
+ */
1435
+ graphTools: config.self === true ? undefined : agentInputs.graphTools,
1425
1436
  };
1426
1437
 
1427
1438
  if (config.allowNested === true) {
@@ -610,6 +610,28 @@ export interface AgentInputs {
610
610
  subagentConfigs?: SubagentConfig[];
611
611
  /** Maximum subagent nesting depth. Default 1 means top-level agents can spawn subagents but subagents cannot nest further. */
612
612
  maxSubagentDepth?: number;
613
+ /**
614
+ * Host-supplied tool instances that must execute IN-PROCESS inside the graph's
615
+ * ToolNode even when the run is event-driven (`toolDefinitions` non-empty). Each
616
+ * instance is bound to the model alongside the schema-only event tools and its
617
+ * name is marked direct, so calls bypass ON_TOOL_EXECUTE dispatch and run inside
618
+ * the Pregel task frame. This is the only execution mode where a tool body may
619
+ * raise a LangGraph `interrupt()` (e.g. a tool built on `askUserQuestion()`) —
620
+ * the host-side event handler runs outside the graph task, where `interrupt()`
621
+ * throws. Do NOT also list these tools in `toolDefinitions` (they would be bound
622
+ * twice). NOT inherited by SELF-SPAWNED subagent children (their config is a
623
+ * shallow spread of the parent's inputs, and child graphs compile without a
624
+ * checkpointer, so an interrupt-capable tool could never pause there) —
625
+ * `buildChildInputs` scrubs the inherited copy; an EXPLICIT child config that
626
+ * lists its own `graphTools` keeps them.
627
+ *
628
+ * Deliberately `GenericTool[]`, not `GraphTools`: the wider union admits
629
+ * schema-only shapes (OpenAI `BindToolsInput`, Google tool objects) that
630
+ * `initializeTools` cannot register in the ToolNode direct map — the model
631
+ * would bind a tool the SDK advertised as in-process but cannot execute.
632
+ * Every entry must be a real executable tool instance with a `name`.
633
+ */
634
+ graphTools?: GenericTool[];
613
635
  }
614
636
 
615
637
  export interface ContextPruningConfig {
package/src/types/run.ts CHANGED
@@ -184,6 +184,15 @@ export type RunConfig = {
184
184
  * eager dispatch unsafe.
185
185
  */
186
186
  eagerEventToolExecution?: EagerEventToolExecutionConfig;
187
+ /**
188
+ * Names of host tools that write to the code-execution sandbox but are not
189
+ * built-in `CODE_EXECUTION_TOOLS` (e.g. LibreChat's create_file/edit_file).
190
+ * Their successful results fold the returned exec `session_id` into the
191
+ * shared code session, so a file such a tool writes is visible to later
192
+ * bash_tool/execute_code calls running in the same sandbox. Host-declared so
193
+ * the SDK stays name-agnostic.
194
+ */
195
+ codeSessionToolNames?: string[];
187
196
  /**
188
197
  * Selects the execution backend for built-in code tools. Omit this to keep
189
198
  * the remote LibreChat Code API sandbox. Set `{ engine: 'local' }` to run
@@ -110,6 +110,12 @@ export type ToolNodeOptions = {
110
110
  directToolNames?: Set<string>;
111
111
  /** Opt-in eager execution for event-driven tool calls. */
112
112
  eagerEventToolExecution?: EagerEventToolExecutionConfig;
113
+ /**
114
+ * Host tool names that write to the code-execution sandbox but are not
115
+ * built-in `CODE_EXECUTION_TOOLS`. Their exec `session_id` is folded into the
116
+ * shared code session so later bash_tool/execute_code calls see written files.
117
+ */
118
+ codeSessionToolNames?: string[];
113
119
  /** Shared per-run eager execution registry populated by the stream handler. */
114
120
  eagerEventToolExecutions?: Map<string, EagerEventToolExecution>;
115
121
  /** Shared per-run per-tool turn counter used by eager and normal event dispatch. */
@@ -53,7 +53,7 @@ export const createTitleRunnable = async (
53
53
 
54
54
  const titlePrompt = ChatPromptTemplate.fromTemplate(
55
55
  _titlePrompt ?? defaultTitlePrompt
56
- ).withConfig({ runName: 'TitlePrompt' });
56
+ ).withConfig({ runName: 'BuildTitlePrompt' });
57
57
 
58
58
  const titleOnlyInnerChain = RunnableSequence.from([titlePrompt, titleLLM]);
59
59
  const combinedInnerChain = RunnableSequence.from([titlePrompt, combinedLLM]);
@@ -67,7 +67,7 @@ export const createTitleRunnable = async (
67
67
  const result = await titleOnlyInnerChain.invoke(input, config);
68
68
  return result as { title: string };
69
69
  },
70
- }).withConfig({ runName: 'TitleOnlyChain' });
70
+ }).withConfig({ runName: 'GenerateTitleOnly' });
71
71
 
72
72
  /** Wrap combinedChain in RunnableLambda to create parent span */
73
73
  const combinedChain = new RunnableLambda({
@@ -78,7 +78,7 @@ export const createTitleRunnable = async (
78
78
  const result = await combinedInnerChain.invoke(input, config);
79
79
  return result as { language: string; title: string };
80
80
  },
81
- }).withConfig({ runName: 'TitleLanguageChain' });
81
+ }).withConfig({ runName: 'GenerateTitleAndDetectLanguage' });
82
82
 
83
83
  /** Runnable to add default values if needed */
84
84
  const addDefaults = new RunnableLambda({
@@ -88,7 +88,7 @@ export const createTitleRunnable = async (
88
88
  language: result?.language ?? 'English',
89
89
  title: result?.title ?? '',
90
90
  }),
91
- }).withConfig({ runName: 'AddDefaults' });
91
+ }).withConfig({ runName: 'ApplyTitleDefaults' });
92
92
 
93
93
  const combinedChainInner = RunnableSequence.from([
94
94
  combinedChain,
@@ -103,7 +103,7 @@ export const createTitleRunnable = async (
103
103
  ): Promise<{ language: string; title: string }> => {
104
104
  return await combinedChainInner.invoke(input, config);
105
105
  },
106
- }).withConfig({ runName: 'CombinedChainWithDefaults' });
106
+ }).withConfig({ runName: 'GenerateTitleAndDetectLanguageWithDefaults' });
107
107
 
108
108
  return new RunnableLambda({
109
109
  func: async (
@@ -124,7 +124,7 @@ export const createTitleRunnable = async (
124
124
 
125
125
  return await combinedChainWithDefaults.invoke(invokeInput, config);
126
126
  },
127
- }).withConfig({ runName: 'TitleGenerator' });
127
+ }).withConfig({ runName: 'GenerateTitle' });
128
128
  };
129
129
 
130
130
  const defaultCompletionPrompt = `Provide a concise, 5-word-or-less title for the conversation, using title case conventions. Only return the title itself.
@@ -138,7 +138,7 @@ export const createCompletionTitleRunnable = async (
138
138
  ): Promise<Runnable> => {
139
139
  const completionPrompt = ChatPromptTemplate.fromTemplate(
140
140
  titlePrompt ?? defaultCompletionPrompt
141
- ).withConfig({ runName: 'CompletionTitlePrompt' });
141
+ ).withConfig({ runName: 'BuildTitlePrompt' });
142
142
 
143
143
  /** Runnable to extract content from model response */
144
144
  const extractContent = new RunnableLambda({
@@ -157,7 +157,7 @@ export const createCompletionTitleRunnable = async (
157
157
  }
158
158
  return { title: content.trim() };
159
159
  },
160
- }).withConfig({ runName: 'ExtractTitle' });
160
+ }).withConfig({ runName: 'ParseTitleFromResponse' });
161
161
 
162
162
  const innerChain = RunnableSequence.from([
163
163
  completionPrompt,
@@ -173,5 +173,5 @@ export const createCompletionTitleRunnable = async (
173
173
  ): Promise<{ title: string }> => {
174
174
  return await innerChain.invoke(input, config);
175
175
  },
176
- }).withConfig({ runName: 'CompletionTitleChain' });
176
+ }).withConfig({ runName: 'GenerateTitle' });
177
177
  };