@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.
- package/dist/cjs/agents/AgentContext.cjs +7 -1
- package/dist/cjs/agents/AgentContext.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +2 -2
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/langfuseToolOutputTracing.cjs +4 -0
- package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -1
- package/dist/cjs/langfuseTraceShaping.cjs +172 -0
- package/dist/cjs/langfuseTraceShaping.cjs.map +1 -0
- package/dist/cjs/run.cjs +2 -2
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/tools/search/keenable-search.cjs +68 -0
- package/dist/cjs/tools/search/keenable-search.cjs.map +1 -0
- package/dist/cjs/tools/search/rerankers.cjs +28 -11
- package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
- package/dist/cjs/tools/search/search.cjs +30 -4
- package/dist/cjs/tools/search/search.cjs.map +1 -1
- package/dist/cjs/tools/search/tool.cjs +14 -6
- package/dist/cjs/tools/search/tool.cjs.map +1 -1
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +12 -1
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
- package/dist/cjs/utils/title.cjs +9 -9
- package/dist/cjs/utils/title.cjs.map +1 -1
- package/dist/esm/agents/AgentContext.mjs +7 -1
- package/dist/esm/agents/AgentContext.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +2 -2
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/langfuseToolOutputTracing.mjs +4 -0
- package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
- package/dist/esm/langfuseTraceShaping.mjs +171 -0
- package/dist/esm/langfuseTraceShaping.mjs.map +1 -0
- package/dist/esm/run.mjs +2 -2
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/tools/search/keenable-search.mjs +66 -0
- package/dist/esm/tools/search/keenable-search.mjs.map +1 -0
- package/dist/esm/tools/search/rerankers.mjs +28 -11
- package/dist/esm/tools/search/rerankers.mjs.map +1 -1
- package/dist/esm/tools/search/search.mjs +30 -4
- package/dist/esm/tools/search/search.mjs.map +1 -1
- package/dist/esm/tools/search/tool.mjs +14 -6
- package/dist/esm/tools/search/tool.mjs.map +1 -1
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +12 -1
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
- package/dist/esm/utils/title.mjs +9 -9
- package/dist/esm/utils/title.mjs.map +1 -1
- package/dist/types/langfuseTraceShaping.d.ts +20 -0
- package/dist/types/tools/search/keenable-search.d.ts +4 -0
- package/dist/types/tools/search/rerankers.d.ts +7 -2
- package/dist/types/tools/search/types.d.ts +38 -1
- package/dist/types/types/graph.d.ts +22 -0
- package/package.json +1 -1
- package/src/agents/AgentContext.ts +9 -0
- package/src/agents/__tests__/AgentContext.test.ts +40 -0
- package/src/graphs/Graph.ts +25 -2
- package/src/graphs/__tests__/composition.smoke.test.ts +53 -0
- package/src/langfuseToolOutputTracing.ts +11 -0
- package/src/langfuseTraceShaping.ts +280 -0
- package/src/llm/anthropic/inherited-stream-events.spec.ts +6 -3
- package/src/run.ts +3 -3
- package/src/specs/langfuse-routing.integration.test.ts +1 -1
- package/src/specs/langfuse-trace-shaping.test.ts +194 -0
- package/src/tools/__tests__/SubagentExecutor.test.ts +44 -0
- package/src/tools/__tests__/hitl.test.ts +85 -0
- package/src/tools/search/jina-reranker.test.ts +70 -1
- package/src/tools/search/keenable-search.ts +98 -0
- package/src/tools/search/keenable.test.ts +183 -0
- package/src/tools/search/rerankers.ts +41 -4
- package/src/tools/search/search.ts +58 -2
- package/src/tools/search/source-processing.test.ts +86 -0
- package/src/tools/search/tool.ts +29 -4
- package/src/tools/search/types.ts +42 -1
- package/src/tools/subagent/SubagentExecutor.ts +11 -0
- package/src/types/graph.ts +22 -0
- 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(
|
|
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 {
|
|
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({
|
|
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({
|
|
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 '
|
|
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', () => {
|
package/src/tools/search/tool.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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,
|