@librechat/agents 3.7.9 → 3.7.10

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.
@@ -1,9 +1,14 @@
1
1
  import axios from 'axios';
2
2
  import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
3
3
  import type * as t from './types';
4
- import { getAttribution, createDefaultLogger } from './utils';
4
+ import {
5
+ getAttribution,
6
+ createDefaultLogger,
7
+ formatErrorForLog,
8
+ } from './utils';
5
9
  import { createKeenableAPI } from './keenable-search';
6
10
  import { createTavilyAPI } from './tavily-search';
11
+ import { createSearchMetrics } from './metrics';
7
12
  import { createCrwAPI } from './crw-search';
8
13
  import { BaseReranker } from './rerankers';
9
14
 
@@ -139,50 +144,67 @@ function createSourceUpdateCallback(sourceMap: Map<string, t.ValidSource>) {
139
144
  };
140
145
  }
141
146
 
147
+ /** Returns undefined without logging when there is nothing to rank: an empty
148
+ * scrape is already counted by the scrape summary, and a missing reranker is
149
+ * reported once at tool construction rather than once per source. */
142
150
  const getHighlights = async ({
143
151
  query,
144
152
  content,
145
153
  reranker,
154
+ metrics,
146
155
  topResults = 5,
147
156
  maxContentLength = DEFAULT_MAX_CONTENT_LENGTH,
148
157
  chunkOptions,
149
- logger,
150
158
  }: {
151
159
  content: string;
152
160
  query: string;
153
161
  reranker?: BaseReranker;
162
+ metrics: t.SearchMetrics;
154
163
  topResults?: number;
155
164
  maxContentLength?: number;
156
165
  chunkOptions?: { chunkSize: number; chunkOverlap: number };
157
- logger?: t.Logger;
158
166
  }): Promise<t.Highlight[] | undefined> => {
159
- const logger_ = logger || createDefaultLogger();
160
-
161
- if (!content) {
162
- logger_.warn('No content provided for highlights');
163
- return;
164
- }
165
- if (!reranker) {
166
- logger_.warn('No reranker provided for highlights');
167
+ if (!content || !reranker) {
167
168
  return;
168
169
  }
169
170
 
171
+ /** Both failures are this reranker's attempt for this source, so they stay
172
+ * in its phase — but they are caught separately: sharing one `catch` would
173
+ * report a reranker that threw as a chunking failure, with a chunk count
174
+ * of zero for text that split fine. */
175
+ const chunkStartedAt = Date.now();
176
+ let documents: string[];
170
177
  try {
171
- const documents = await chunker.splitText(
178
+ documents = await chunker.splitText(
172
179
  truncateContent(content, maxContentLength),
173
180
  chunkOptions
174
181
  );
175
- if (Array.isArray(documents)) {
176
- return await reranker.rerank(query, documents, topResults);
177
- } else {
178
- logger_.error(
179
- 'Expected documents to be an array, got:',
180
- typeof documents
181
- );
182
- return;
183
- }
184
182
  } catch (error) {
185
- logger_.error('Error in content processing:', error);
183
+ metrics.recordRerank({
184
+ provider: reranker.provider,
185
+ chunks: 0,
186
+ results: 0,
187
+ durationMs: Date.now() - chunkStartedAt,
188
+ reason: 'chunk_error',
189
+ error: formatErrorForLog(error),
190
+ });
191
+ return;
192
+ }
193
+
194
+ const rerankStartedAt = Date.now();
195
+ try {
196
+ return await reranker.rerank(query, documents, topResults, metrics);
197
+ } catch (error) {
198
+ /** The bundled rerankers absorb their own failures and record the
199
+ * observation themselves; a custom implementation may reject instead. */
200
+ metrics.recordRerank({
201
+ provider: reranker.provider,
202
+ chunks: documents.length,
203
+ results: 0,
204
+ durationMs: Date.now() - rerankStartedAt,
205
+ reason: 'error',
206
+ error: formatErrorForLog(error),
207
+ });
186
208
  return;
187
209
  }
188
210
  };
@@ -598,54 +620,94 @@ export const createSourceProcessor = (
598
620
  };
599
621
  }
600
622
 
601
- logger_.error(
602
- `Error scraping ${url}: ${response.error ?? 'Unknown error'}`
603
- );
604
623
  return { url, attribution, error: true, content: '' };
605
624
  };
606
625
 
607
626
  const addHighlights = async (
608
627
  result: t.ScrapeResult,
609
628
  query: string,
629
+ metrics: t.SearchMetrics,
610
630
  onGetHighlights: t.SearchToolConfig['onGetHighlights']
611
631
  ): Promise<t.ScrapeResult> => {
612
- if (result.error != null) {
613
- return result;
632
+ const highlights = await getHighlights({
633
+ query,
634
+ reranker,
635
+ metrics,
636
+ topResults,
637
+ content: result.content,
638
+ maxContentLength,
639
+ chunkOptions,
640
+ });
641
+ if (onGetHighlights) {
642
+ onGetHighlights(result.url);
614
643
  }
644
+ return { ...result, highlights };
645
+ };
646
+
647
+ /** Scrape and rerank one link, recording the single observation that link
648
+ * contributes to the run summary — the only place a per-link outcome is
649
+ * reported, so nothing here logs per link. */
650
+ const processLink = async (
651
+ url: string,
652
+ response: t.AnyScraperResponse,
653
+ query: string,
654
+ metrics: t.SearchMetrics,
655
+ onGetHighlights: t.SearchToolConfig['onGetHighlights']
656
+ ): Promise<t.ScrapeResult> => {
657
+ /** `extractContent`/`extractMetadata` are the scraper implementation's
658
+ * code: one malformed response must not reject alongside its siblings,
659
+ * which would discard their results and their observations with them. */
660
+ let scraped: t.ScrapeResult;
615
661
  try {
616
- const highlights = await getHighlights({
617
- query,
618
- reranker,
619
- topResults,
620
- content: result.content,
621
- maxContentLength,
622
- chunkOptions,
623
- logger: logger_,
624
- });
625
- if (onGetHighlights) {
626
- onGetHighlights(result.url);
627
- }
628
- return { ...result, highlights };
662
+ scraped = processResponse(url, response);
629
663
  } catch (error) {
630
- logger_.error('Error processing scraped content:', error);
631
- return result;
664
+ metrics.recordScrape({ url, error: String(error) });
665
+ return { url, error: true, content: '' };
632
666
  }
667
+ if (scraped.error === true) {
668
+ metrics.recordScrape({ url, error: response.error ?? 'Unknown error' });
669
+ return scraped;
670
+ }
671
+ /** `getHighlights` absorbs its own failures, so only a throwing
672
+ * `onGetHighlights` consumer reaches here; one bad callback must not
673
+ * discard the sibling links awaiting alongside it. */
674
+ const result = await addHighlights(
675
+ scraped,
676
+ query,
677
+ metrics,
678
+ onGetHighlights
679
+ ).catch((error) => {
680
+ logger_.error('Error processing scraped content:', error);
681
+ return scraped;
682
+ });
683
+ metrics.recordScrape({
684
+ url,
685
+ chars: result.content.length,
686
+ highlights: result.highlights?.length ?? 0,
687
+ });
688
+ return result;
633
689
  };
634
690
 
635
691
  const webScraper = {
636
692
  scrapeMany: async ({
637
693
  query,
638
694
  links,
695
+ metrics,
639
696
  onGetHighlights,
640
697
  }: {
641
698
  query: string;
642
699
  links: string[];
700
+ metrics: t.SearchMetrics;
643
701
  onGetHighlights: t.SearchToolConfig['onGetHighlights'];
644
702
  }): Promise<Array<t.ScrapeResult>> => {
645
- logger_.debug(`Scraping ${links.length} links`);
646
- try {
647
- let responses: Array<[string, t.AnyScraperResponse]>;
703
+ let responses: Array<[string, t.AnyScraperResponse]>;
648
704
 
705
+ /** Scoped to acquisition alone. A batch `scrapeUrls` that rejects
706
+ * yields no per-link responses, so nothing downstream will ever report
707
+ * these links — without recording them here a total outage would flush
708
+ * no scrape summary at all, reading exactly like a search that never
709
+ * scraped anything. */
710
+ try {
649
711
  if (scraper.scrapeUrls) {
650
712
  responses = await scraper.scrapeUrls(links);
651
713
  } else {
@@ -653,25 +715,32 @@ export const createSourceProcessor = (
653
715
  links.map((link) =>
654
716
  scraper
655
717
  .scrapeUrl(link, {})
656
- .catch((error): [string, t.AnyScraperResponse] => {
657
- logger_.error(`Error scraping ${link}:`, error);
658
- return [link, { success: false, error: String(error) }];
659
- })
718
+ .catch((error): [string, t.AnyScraperResponse] => [
719
+ link,
720
+ { success: false, error: String(error) },
721
+ ])
660
722
  )
661
723
  );
662
724
  }
725
+ } catch (error) {
726
+ logger_.error('Error in scrapeMany:', error);
727
+ const message = String(error);
728
+ for (const link of links) {
729
+ metrics.recordScrape({ url: link, error: message });
730
+ }
731
+ return [];
732
+ }
663
733
 
664
- const withHighlights = await Promise.all(
734
+ try {
735
+ return await Promise.all(
665
736
  responses.map(([url, response]) =>
666
- addHighlights(
667
- processResponse(url, response),
668
- query,
669
- onGetHighlights
670
- )
737
+ processLink(url, response, query, metrics, onGetHighlights)
671
738
  )
672
739
  );
673
- return withHighlights;
674
740
  } catch (error) {
741
+ /** `processLink` absorbs its own failures and has already recorded
742
+ * whatever it reached, so this only preserves the soft failure the
743
+ * caller has always seen — it must not re-count these links. */
675
744
  logger_.error('Error in scrapeMany:', error);
676
745
  return [];
677
746
  }
@@ -682,12 +751,14 @@ export const createSourceProcessor = (
682
751
  links,
683
752
  query,
684
753
  target,
754
+ metrics,
685
755
  onGetHighlights,
686
756
  onContentScraped,
687
757
  }: {
688
758
  links: string[];
689
759
  query: string;
690
760
  target: number;
761
+ metrics: t.SearchMetrics;
691
762
  onGetHighlights: t.SearchToolConfig['onGetHighlights'];
692
763
  onContentScraped?: (link: string, update?: Partial<t.ValidSource>) => void;
693
764
  }): Promise<void> => {
@@ -695,6 +766,7 @@ export const createSourceProcessor = (
695
766
  // const remainingLinks = links.slice(target).reverse();
696
767
  const results = await webScraper.scrapeMany({
697
768
  query,
769
+ metrics,
698
770
  links: initialLinks,
699
771
  onGetHighlights,
700
772
  });
@@ -719,7 +791,11 @@ export const createSourceProcessor = (
719
791
  news,
720
792
  proMode = true,
721
793
  onGetHighlights,
794
+ metrics: ownerMetrics,
722
795
  }: t.ProcessSourcesFields): Promise<t.SearchResultData> => {
796
+ /** The caller owns the collector when it supplies one — it has phases of
797
+ * its own to fold in and flushes them together. */
798
+ const metrics = ownerMetrics ?? createSearchMetrics(logger_);
723
799
  try {
724
800
  if (!result.data) {
725
801
  return {
@@ -759,6 +835,7 @@ export const createSourceProcessor = (
759
835
  const onContentScraped = createSourceUpdateCallback(wikiSourceMap);
760
836
  await fetchContents({
761
837
  query,
838
+ metrics,
762
839
  target: 1,
763
840
  onGetHighlights,
764
841
  onContentScraped,
@@ -809,6 +886,7 @@ export const createSourceProcessor = (
809
886
  promises.push(
810
887
  fetchContents({
811
888
  query,
889
+ metrics,
812
890
  onGetHighlights,
813
891
  onContentScraped,
814
892
  links: organicLinks,
@@ -822,6 +900,7 @@ export const createSourceProcessor = (
822
900
  promises.push(
823
901
  fetchContents({
824
902
  query,
903
+ metrics,
825
904
  onGetHighlights,
826
905
  onContentScraped,
827
906
  links: topStoryLinks,
@@ -851,6 +930,10 @@ export const createSourceProcessor = (
851
930
  ...result.data,
852
931
  error: error instanceof Error ? error.message : String(error),
853
932
  };
933
+ } finally {
934
+ if (ownerMetrics == null) {
935
+ metrics.flush();
936
+ }
854
937
  }
855
938
  };
856
939
 
@@ -21,6 +21,7 @@ import { INTENT_PROPERTY } from '@/tools/intentArg';
21
21
  import { createCrwScraper } from './crw-scraper';
22
22
  import { expandHighlights } from './highlights';
23
23
  import { formatResultsForLLM } from './format';
24
+ import { createSearchMetrics } from './metrics';
24
25
  import { createDefaultLogger } from './utils';
25
26
  import { createReranker } from './rerankers';
26
27
  import { Constants } from '@/common';
@@ -65,6 +66,50 @@ export function resolveSearchOutcome(
65
66
  return `Found ${count} result${count === 1 ? '' : 's'} for "${query}"`;
66
67
  }
67
68
 
69
+ /** Distinct rows across the main search's two collections. SearXNG derives
70
+ * both from one result array — a row matching its news heuristic lands in
71
+ * `organic` and `topStories` alike — so summing the lengths would report
72
+ * more rows than the provider actually returned. */
73
+ const countWebResults = (data: t.SearchResultData): number => {
74
+ const organic = data.organic ?? [];
75
+ const topStories = data.topStories ?? [];
76
+ if (organic.length === 0 || topStories.length === 0) {
77
+ return organic.length + topStories.length;
78
+ }
79
+
80
+ const links = new Set<string>();
81
+ let unlinked = 0;
82
+ for (const row of [...organic, ...topStories]) {
83
+ if (row.link) {
84
+ links.add(row.link);
85
+ continue;
86
+ }
87
+ /** Nothing to dedupe a blank link against, so it counts on its own. */
88
+ unlinked += 1;
89
+ }
90
+ return links.size + unlinked;
91
+ };
92
+
93
+ /** Rows a sub-search contributed, for the run summary's per-type breakdown. */
94
+ const countResults = (
95
+ type: t.SubSearchType,
96
+ data?: t.SearchResultData
97
+ ): number => {
98
+ if (data == null) {
99
+ return 0;
100
+ }
101
+ if (type === 'images') {
102
+ return data.images?.length ?? 0;
103
+ }
104
+ if (type === 'videos') {
105
+ return data.videos?.length ?? 0;
106
+ }
107
+ if (type === 'news') {
108
+ return data.news?.length ?? 0;
109
+ }
110
+ return countWebResults(data);
111
+ };
112
+
68
113
  /**
69
114
  * Executes parallel searches and merges the results,
70
115
  * deduplicating top stories by link
@@ -79,6 +124,8 @@ export async function executeParallelSearches({
79
124
  videos,
80
125
  news,
81
126
  logger,
127
+ provider = 'unknown',
128
+ metrics,
82
129
  }: {
83
130
  searchAPI: ReturnType<typeof createSearchAPI>;
84
131
  query: string;
@@ -89,78 +136,81 @@ export async function executeParallelSearches({
89
136
  videos: boolean;
90
137
  news: boolean;
91
138
  logger: t.Logger;
139
+ /** Labels the provider in the run summary. Optional so the pre-existing
140
+ * call contract still holds for callers outside this package. */
141
+ provider?: string;
142
+ /** Collector owned by the caller. Without one, this call opens and flushes
143
+ * its own, so a direct caller still gets the single summary line. */
144
+ metrics?: t.SearchMetrics;
92
145
  }): Promise<t.SearchResult> {
146
+ const collector = metrics ?? createSearchMetrics(logger);
147
+ /** A rejected main search is fatal, but rethrowing it from the task itself
148
+ * would settle `Promise.all` while its siblings are still in flight, and
149
+ * their observations would then land in an already-flushed phase. Every
150
+ * task resolves; the failure is held here and raised once all have run. */
151
+ let mainFailure: { error: unknown } | undefined;
152
+
153
+ /** Sub-searches resolve rather than reject so their siblings still merge.
154
+ * A rejected MAIN search rethrows the provider's own error below — callers
155
+ * have always seen that error object, not a wrapped copy. */
156
+ const runSearch = async (type: t.SubSearchType): Promise<t.SearchResult> => {
157
+ const startedAt = Date.now();
158
+ try {
159
+ const result = await searchAPI.getSources({
160
+ query,
161
+ date,
162
+ country,
163
+ safeSearch,
164
+ ...(type !== 'web' && { type }),
165
+ });
166
+ collector.recordSearch({
167
+ provider,
168
+ type,
169
+ results: countResults(type, result.data),
170
+ durationMs: Date.now() - startedAt,
171
+ error: result.success ? undefined : (result.error ?? 'Search failed'),
172
+ });
173
+ return result;
174
+ } catch (error) {
175
+ const message = error instanceof Error ? error.message : String(error);
176
+ collector.recordSearch({
177
+ provider,
178
+ type,
179
+ results: 0,
180
+ durationMs: Date.now() - startedAt,
181
+ error: message,
182
+ thrown: true,
183
+ });
184
+ if (type === 'web') {
185
+ mainFailure = { error };
186
+ }
187
+ return { success: false, error: message };
188
+ }
189
+ };
190
+
93
191
  // Prepare all search tasks to run in parallel
94
- const searchTasks: Promise<t.SearchResult>[] = [
95
- // Main search
96
- searchAPI.getSources({
97
- query,
98
- date,
99
- country,
100
- safeSearch,
101
- }),
102
- ];
192
+ const searchTasks: Promise<t.SearchResult>[] = [runSearch('web')];
103
193
 
104
194
  if (images) {
105
- searchTasks.push(
106
- searchAPI
107
- .getSources({
108
- query,
109
- date,
110
- country,
111
- safeSearch,
112
- type: 'images',
113
- })
114
- .catch((error) => {
115
- logger.error('Error fetching images:', error);
116
- return {
117
- success: false,
118
- error: `Images search failed: ${error instanceof Error ? error.message : String(error)}`,
119
- };
120
- })
121
- );
195
+ searchTasks.push(runSearch('images'));
122
196
  }
123
197
  if (videos) {
124
- searchTasks.push(
125
- searchAPI
126
- .getSources({
127
- query,
128
- date,
129
- country,
130
- safeSearch,
131
- type: 'videos',
132
- })
133
- .catch((error) => {
134
- logger.error('Error fetching videos:', error);
135
- return {
136
- success: false,
137
- error: `Videos search failed: ${error instanceof Error ? error.message : String(error)}`,
138
- };
139
- })
140
- );
198
+ searchTasks.push(runSearch('videos'));
141
199
  }
142
200
  if (news) {
143
- searchTasks.push(
144
- searchAPI
145
- .getSources({
146
- query,
147
- date,
148
- country,
149
- safeSearch,
150
- type: 'news',
151
- })
152
- .catch((error) => {
153
- logger.error('Error fetching news:', error);
154
- return {
155
- success: false,
156
- error: `News search failed: ${error instanceof Error ? error.message : String(error)}`,
157
- };
158
- })
159
- );
201
+ searchTasks.push(runSearch('news'));
160
202
  }
161
203
 
162
- // Run all searches in parallel
204
+ // Run all searches in parallel. No task rejects, so every observation is
205
+ // recorded before the collector is flushed or a failure is raised.
163
206
  const results = await Promise.all(searchTasks);
207
+ if (metrics == null) {
208
+ collector.flush();
209
+ }
210
+
211
+ if (mainFailure != null) {
212
+ throw mainFailure.error;
213
+ }
164
214
 
165
215
  // Get the main search result (first result)
166
216
  const mainResult = results[0];
@@ -239,6 +289,7 @@ export async function executeParallelSearches({
239
289
 
240
290
  function createSearchProcessor({
241
291
  searchAPI,
292
+ provider,
242
293
  safeSearch,
243
294
  supportsImages,
244
295
  supportsVideos,
@@ -249,6 +300,7 @@ function createSearchProcessor({
249
300
  separatorExpandBy,
250
301
  logger,
251
302
  }: {
303
+ provider: string;
252
304
  safeSearch: t.SearchToolConfig['safeSearch'];
253
305
  supportsImages: boolean;
254
306
  supportsVideos: boolean;
@@ -281,6 +333,10 @@ function createSearchProcessor({
281
333
  videos?: boolean;
282
334
  news?: boolean;
283
335
  }): Promise<t.SearchResultData> {
336
+ /** One collector for the whole call: the provider, scrape, and rerank
337
+ * phases each fold into counters and flush together, so a search costs a
338
+ * bounded handful of lines instead of a few per source. */
339
+ const metrics = createSearchMetrics(logger);
284
340
  try {
285
341
  // Execute parallel searches and merge results
286
342
  const searchResult = await executeParallelSearches({
@@ -293,6 +349,8 @@ function createSearchProcessor({
293
349
  videos: supportsVideos && videos,
294
350
  news: supportsNews && news,
295
351
  logger,
352
+ provider,
353
+ metrics,
296
354
  });
297
355
 
298
356
  onSearchResults?.(searchResult);
@@ -300,6 +358,7 @@ function createSearchProcessor({
300
358
  const processedSources = await sourceProcessor.processSources({
301
359
  query,
302
360
  news,
361
+ metrics,
303
362
  result: searchResult,
304
363
  proMode,
305
364
  onGetHighlights,
@@ -322,6 +381,8 @@ function createSearchProcessor({
322
381
  relatedSearches: [],
323
382
  error: error instanceof Error ? error.message : String(error),
324
383
  };
384
+ } finally {
385
+ metrics.flush();
325
386
  }
326
387
  };
327
388
  }
@@ -585,7 +646,9 @@ export const createSearchTool = (
585
646
  logger,
586
647
  });
587
648
 
588
- if (!selectedReranker) {
649
+ /** `none` is a deliberate opt-out that `createReranker` already reports;
650
+ * only an unusable configuration warrants a warning here. */
651
+ if (!selectedReranker && rerankerType !== 'none') {
589
652
  logger.warn('No reranker selected. Using default ranking.');
590
653
  }
591
654
 
@@ -605,6 +668,7 @@ export const createSearchTool = (
605
668
 
606
669
  const search = createSearchProcessor({
607
670
  searchAPI,
671
+ provider: searchProvider,
608
672
  safeSearch,
609
673
  // Keenable is organic-only: its API ignores `type`, so image/news
610
674
  // sub-searches would spend rate limit and merge nothing.