@librechat/agents 3.7.9 → 3.7.11
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.
- package/dist/cjs/graphs/Graph.cjs +11 -1
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/graphs/MultiAgentGraph.cjs +36 -3
- package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
- package/dist/cjs/langfuse.cjs +6 -2
- package/dist/cjs/langfuse.cjs.map +1 -1
- package/dist/cjs/run.cjs +5 -1
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +12 -2
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/search/metrics.cjs +234 -0
- package/dist/cjs/tools/search/metrics.cjs.map +1 -0
- package/dist/cjs/tools/search/rerankers.cjs +80 -68
- package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
- package/dist/cjs/tools/search/search.cjs +95 -45
- package/dist/cjs/tools/search/search.cjs.map +1 -1
- package/dist/cjs/tools/search/tool.cjs +77 -49
- package/dist/cjs/tools/search/tool.cjs.map +1 -1
- package/dist/cjs/tools/search/utils.cjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +11 -1
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/graphs/MultiAgentGraph.mjs +36 -3
- package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
- package/dist/esm/langfuse.mjs +6 -2
- package/dist/esm/langfuse.mjs.map +1 -1
- package/dist/esm/run.mjs +5 -1
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +12 -2
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/search/metrics.mjs +234 -0
- package/dist/esm/tools/search/metrics.mjs.map +1 -0
- package/dist/esm/tools/search/rerankers.mjs +80 -68
- package/dist/esm/tools/search/rerankers.mjs.map +1 -1
- package/dist/esm/tools/search/search.mjs +96 -46
- package/dist/esm/tools/search/search.mjs.map +1 -1
- package/dist/esm/tools/search/tool.mjs +77 -49
- package/dist/esm/tools/search/tool.mjs.map +1 -1
- package/dist/esm/tools/search/utils.mjs.map +1 -1
- package/dist/types/langfuse.d.ts +5 -1
- package/dist/types/tools/ToolNode.d.ts +4 -1
- package/dist/types/tools/search/metrics.d.ts +15 -0
- package/dist/types/tools/search/rerankers.d.ts +30 -5
- package/dist/types/tools/search/tool.d.ts +7 -1
- package/dist/types/tools/search/types.d.ts +88 -4
- package/dist/types/tools/search/utils.d.ts +2 -10
- package/dist/types/types/tools.d.ts +5 -0
- package/package.json +1 -1
- package/src/graphs/Graph.ts +10 -0
- package/src/graphs/MultiAgentGraph.ts +71 -2
- package/src/langfuse.ts +12 -0
- package/src/run.ts +4 -0
- package/src/tools/ToolNode.ts +19 -0
- package/src/tools/search/metrics.ts +400 -0
- package/src/tools/search/rerankers.ts +160 -97
- package/src/tools/search/search.ts +139 -56
- package/src/tools/search/tool.ts +126 -62
- package/src/tools/search/types.ts +104 -4
- package/src/tools/search/utils.ts +2 -10
- package/src/types/tools.ts +5 -0
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
613
|
-
|
|
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
|
-
|
|
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
|
-
|
|
631
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
658
|
-
|
|
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
|
-
|
|
734
|
+
try {
|
|
735
|
+
return await Promise.all(
|
|
665
736
|
responses.map(([url, response]) =>
|
|
666
|
-
|
|
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
|
|
package/src/tools/search/tool.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|