@librechat/agents 3.2.35 → 3.2.36

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 (66) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +74 -1
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/agents/projection.cjs +25 -0
  4. package/dist/cjs/agents/projection.cjs.map +1 -0
  5. package/dist/cjs/graphs/Graph.cjs +3 -18
  6. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  7. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +26 -4
  8. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
  9. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +20 -0
  10. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  11. package/dist/cjs/main.cjs +5 -0
  12. package/dist/cjs/messages/budget.cjs +23 -0
  13. package/dist/cjs/messages/budget.cjs.map +1 -0
  14. package/dist/cjs/messages/cache.cjs +1 -0
  15. package/dist/cjs/messages/cache.cjs.map +1 -1
  16. package/dist/cjs/messages/index.cjs +1 -0
  17. package/dist/cjs/tools/search/format.cjs +91 -2
  18. package/dist/cjs/tools/search/format.cjs.map +1 -1
  19. package/dist/cjs/tools/search/tool.cjs +4 -3
  20. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  21. package/dist/esm/agents/AgentContext.mjs +75 -2
  22. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  23. package/dist/esm/agents/projection.mjs +25 -0
  24. package/dist/esm/agents/projection.mjs.map +1 -0
  25. package/dist/esm/graphs/Graph.mjs +1 -16
  26. package/dist/esm/graphs/Graph.mjs.map +1 -1
  27. package/dist/esm/llm/anthropic/utils/message_inputs.mjs +26 -4
  28. package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
  29. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +20 -0
  30. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  31. package/dist/esm/main.mjs +4 -2
  32. package/dist/esm/messages/budget.mjs +23 -0
  33. package/dist/esm/messages/budget.mjs.map +1 -0
  34. package/dist/esm/messages/cache.mjs +1 -1
  35. package/dist/esm/messages/cache.mjs.map +1 -1
  36. package/dist/esm/messages/index.mjs +1 -0
  37. package/dist/esm/tools/search/format.mjs +91 -2
  38. package/dist/esm/tools/search/format.mjs.map +1 -1
  39. package/dist/esm/tools/search/tool.mjs +4 -3
  40. package/dist/esm/tools/search/tool.mjs.map +1 -1
  41. package/dist/types/agents/AgentContext.d.ts +30 -1
  42. package/dist/types/agents/projection.d.ts +26 -0
  43. package/dist/types/index.d.ts +1 -0
  44. package/dist/types/messages/budget.d.ts +11 -0
  45. package/dist/types/messages/cache.d.ts +7 -0
  46. package/dist/types/messages/index.d.ts +1 -0
  47. package/dist/types/tools/search/format.d.ts +4 -1
  48. package/dist/types/tools/search/types.d.ts +7 -0
  49. package/package.json +1 -1
  50. package/src/agents/AgentContext.ts +103 -2
  51. package/src/agents/__tests__/AgentContext.test.ts +229 -0
  52. package/src/agents/__tests__/projection.test.ts +73 -0
  53. package/src/agents/projection.ts +46 -0
  54. package/src/graphs/Graph.ts +1 -29
  55. package/src/index.ts +3 -0
  56. package/src/llm/anthropic/utils/cross-provider-reasoning.test.ts +317 -0
  57. package/src/llm/anthropic/utils/message_inputs.ts +78 -16
  58. package/src/llm/bedrock/utils/cross-provider-reasoning.test.ts +131 -0
  59. package/src/llm/bedrock/utils/message_inputs.ts +35 -0
  60. package/src/messages/budget.ts +32 -0
  61. package/src/messages/cache.ts +1 -1
  62. package/src/messages/index.ts +1 -0
  63. package/src/tools/search/format.test.ts +242 -0
  64. package/src/tools/search/format.ts +122 -5
  65. package/src/tools/search/tool.ts +5 -1
  66. package/src/tools/search/types.ts +7 -0
@@ -1,6 +1,7 @@
1
1
  export * from './core';
2
2
  export * from './ids';
3
3
  export * from './prune';
4
+ export * from './budget';
4
5
  export * from './format';
5
6
  export * from './cache';
6
7
  export * from './anthropicToolCache';
@@ -0,0 +1,242 @@
1
+ import type * as t from './types';
2
+ import { formatResultsForLLM, resolveMaxLLMOutputChars } from './format';
3
+
4
+ const makeOrganic = (
5
+ link: string,
6
+ highlights: t.Highlight[]
7
+ ): t.ProcessedOrganic => ({
8
+ link,
9
+ title: `Title for ${link}`,
10
+ snippet: `Snippet for ${link}`,
11
+ highlights,
12
+ });
13
+
14
+ const highlight = (text: string, score = 0.9): t.Highlight => ({ text, score });
15
+
16
+ const reference = (url: string, originalIndex = 0): t.UsedReferences[number] => ({
17
+ type: 'link',
18
+ originalIndex,
19
+ reference: { originalUrl: url, title: 'Ref', text: 'ref' },
20
+ });
21
+
22
+ const countHighlightBlocks = (output: string): number =>
23
+ (output.match(/### Highlight \d+/g) ?? []).length;
24
+
25
+ const OMISSION_MARKER = 'omitted to fit the context budget';
26
+
27
+ describe('resolveMaxLLMOutputChars', () => {
28
+ const originalEnv = process.env.SEARCH_MAX_LLM_OUTPUT_CHARS;
29
+
30
+ afterEach(() => {
31
+ if (originalEnv == null) {
32
+ delete process.env.SEARCH_MAX_LLM_OUTPUT_CHARS;
33
+ } else {
34
+ process.env.SEARCH_MAX_LLM_OUTPUT_CHARS = originalEnv;
35
+ }
36
+ });
37
+
38
+ test('falls back to the 50,000 char default when nothing is configured', () => {
39
+ delete process.env.SEARCH_MAX_LLM_OUTPUT_CHARS;
40
+ expect(resolveMaxLLMOutputChars()).toBe(50000);
41
+ expect(resolveMaxLLMOutputChars(0)).toBe(50000);
42
+ expect(resolveMaxLLMOutputChars(-100)).toBe(50000);
43
+ });
44
+
45
+ test('honors the SEARCH_MAX_LLM_OUTPUT_CHARS env var', () => {
46
+ process.env.SEARCH_MAX_LLM_OUTPUT_CHARS = '777';
47
+ expect(resolveMaxLLMOutputChars()).toBe(777);
48
+ expect(resolveMaxLLMOutputChars(0)).toBe(777);
49
+ });
50
+
51
+ test('an explicit positive config value wins over env and default', () => {
52
+ process.env.SEARCH_MAX_LLM_OUTPUT_CHARS = '777';
53
+ expect(resolveMaxLLMOutputChars(1234)).toBe(1234);
54
+ });
55
+
56
+ test('ignores a non-numeric env var', () => {
57
+ process.env.SEARCH_MAX_LLM_OUTPUT_CHARS = 'not-a-number';
58
+ expect(resolveMaxLLMOutputChars()).toBe(50000);
59
+ });
60
+ });
61
+
62
+ describe('formatResultsForLLM highlight budget', () => {
63
+ test('keeps whole highlights in relevance order until the budget is hit', () => {
64
+ const results: t.SearchResultData = {
65
+ organic: [
66
+ makeOrganic('https://a.com', [highlight('A'.repeat(100))]),
67
+ makeOrganic('https://b.com', [highlight('B'.repeat(100))]),
68
+ ],
69
+ };
70
+
71
+ const { output } = formatResultsForLLM(0, results, 100);
72
+
73
+ expect(output).toContain('A'.repeat(100));
74
+ expect(output).not.toContain('B'.repeat(100));
75
+ expect(countHighlightBlocks(output)).toBe(1);
76
+ expect(output).toContain('_[1 additional highlight omitted to fit the context budget');
77
+ });
78
+
79
+ test('truncates the boundary highlight when meaningful room remains', () => {
80
+ const results: t.SearchResultData = {
81
+ organic: [makeOrganic('https://a.com', [highlight('A'.repeat(1000))])],
82
+ };
83
+
84
+ const { output } = formatResultsForLLM(0, results, 500);
85
+
86
+ expect(output).toContain('…[truncated]');
87
+ expect(output).toContain('A'.repeat(500));
88
+ expect(output).not.toContain('A'.repeat(501));
89
+ expect(output).toContain('_[1 additional highlight omitted to fit the context budget');
90
+ });
91
+
92
+ test('drops the boundary highlight entirely when too little room remains', () => {
93
+ const results: t.SearchResultData = {
94
+ organic: [
95
+ makeOrganic('https://a.com', [highlight('A'.repeat(100))]),
96
+ makeOrganic('https://b.com', [highlight('B'.repeat(100))]),
97
+ ],
98
+ };
99
+
100
+ const { output } = formatResultsForLLM(0, results, 150);
101
+
102
+ expect(output).toContain('A'.repeat(100));
103
+ expect(output).not.toContain('…[truncated]');
104
+ expect(output).not.toContain('B');
105
+ expect(countHighlightBlocks(output)).toBe(1);
106
+ });
107
+
108
+ test('always keeps snippets, titles, and URLs even when all highlights are dropped', () => {
109
+ const results: t.SearchResultData = {
110
+ organic: [makeOrganic('https://a.com', [highlight('A'.repeat(100))])],
111
+ };
112
+
113
+ const { output } = formatResultsForLLM(0, results, 10);
114
+
115
+ expect(output).toContain('URL: https://a.com');
116
+ expect(output).toContain('Summary: Snippet for https://a.com');
117
+ expect(output).toContain('"Title for https://a.com"');
118
+ expect(countHighlightBlocks(output)).toBe(0);
119
+ expect(output).toContain('_[1 additional highlight omitted to fit the context budget');
120
+ });
121
+
122
+ test('emits no omission marker when every highlight fits the budget', () => {
123
+ const results: t.SearchResultData = {
124
+ organic: [
125
+ makeOrganic('https://a.com', [highlight('A'.repeat(100))]),
126
+ makeOrganic('https://b.com', [highlight('B'.repeat(100))]),
127
+ ],
128
+ };
129
+
130
+ const { output } = formatResultsForLLM(0, results, 50000);
131
+
132
+ expect(output).toContain('A'.repeat(100));
133
+ expect(output).toContain('B'.repeat(100));
134
+ expect(countHighlightBlocks(output)).toBe(2);
135
+ expect(output).not.toContain(OMISSION_MARKER);
136
+ });
137
+
138
+ test('drops references with no surviving marker when truncating', () => {
139
+ const withRefs = highlight('A'.repeat(1000));
140
+ withRefs.references = [reference('https://cited.example')];
141
+ const results: t.SearchResultData = {
142
+ organic: [makeOrganic('https://a.com', [withRefs])],
143
+ };
144
+
145
+ const { output, references } = formatResultsForLLM(0, results, 500);
146
+
147
+ expect(output).toContain('…[truncated]');
148
+ expect(output).not.toContain('Core References');
149
+ expect(output).not.toContain('https://cited.example');
150
+ expect(references).toHaveLength(0);
151
+ });
152
+
153
+ test('keeps references whose marker survives truncation and drops the rest', () => {
154
+ const withRefs = highlight(`(link#1) ${'A'.repeat(1000)} (link#2)`);
155
+ withRefs.references = [
156
+ reference('https://one.example', 0),
157
+ reference('https://two.example', 1),
158
+ ];
159
+ const results: t.SearchResultData = {
160
+ organic: [makeOrganic('https://a.com', [withRefs])],
161
+ };
162
+
163
+ const { output, references } = formatResultsForLLM(0, results, 500);
164
+
165
+ expect(output).toContain('…[truncated]');
166
+ expect(output).toContain('https://one.example');
167
+ expect(output).not.toContain('https://two.example');
168
+ expect(references).toHaveLength(1);
169
+ expect(references[0].link).toBe('https://one.example');
170
+ });
171
+
172
+ test('stops at the boundary highlight — no lower-ranked highlight slips in', () => {
173
+ const results: t.SearchResultData = {
174
+ organic: [
175
+ makeOrganic('https://a.com', [
176
+ highlight('A'.repeat(100), 0.9),
177
+ highlight('B'.repeat(300), 0.8),
178
+ highlight('C'.repeat(10), 0.7),
179
+ ]),
180
+ ],
181
+ };
182
+
183
+ const { output } = formatResultsForLLM(0, results, 150);
184
+
185
+ expect(output).toContain('A'.repeat(100));
186
+ expect(output).not.toContain('B'.repeat(300));
187
+ expect(output).not.toContain('C'.repeat(10));
188
+ expect(output).not.toContain('…[truncated]');
189
+ expect(countHighlightBlocks(output)).toBe(1);
190
+ });
191
+
192
+ test('keeps references on a whole highlight that fits the budget', () => {
193
+ const withRefs = highlight('A'.repeat(100));
194
+ withRefs.references = [reference('https://cited.example')];
195
+ const results: t.SearchResultData = {
196
+ organic: [makeOrganic('https://a.com', [withRefs])],
197
+ };
198
+
199
+ const { output, references } = formatResultsForLLM(0, results, 50000);
200
+
201
+ expect(output).toContain('Core References');
202
+ expect(references).toHaveLength(1);
203
+ expect(references[0].link).toBe('https://cited.example');
204
+ });
205
+
206
+ test('skips blank highlights instead of charging them against the budget', () => {
207
+ const results: t.SearchResultData = {
208
+ organic: [
209
+ makeOrganic('https://a.com', [
210
+ highlight(' \n\t '),
211
+ highlight('A'.repeat(100)),
212
+ ]),
213
+ ],
214
+ };
215
+
216
+ const { output } = formatResultsForLLM(0, results, 100);
217
+
218
+ expect(output).toContain('A'.repeat(100));
219
+ expect(output).not.toContain('…[truncated]');
220
+ expect(countHighlightBlocks(output)).toBe(1);
221
+ expect(output).not.toContain(OMISSION_MARKER);
222
+ });
223
+
224
+ test('spends the budget across organic results before news results', () => {
225
+ const results: t.SearchResultData = {
226
+ organic: [makeOrganic('https://a.com', [highlight('A'.repeat(100))])],
227
+ topStories: [
228
+ {
229
+ link: 'https://news.com',
230
+ title: 'Story',
231
+ highlights: [highlight('N'.repeat(100))],
232
+ },
233
+ ],
234
+ };
235
+
236
+ const { output } = formatResultsForLLM(0, results, 100);
237
+
238
+ expect(output).toContain('A'.repeat(100));
239
+ expect(output).not.toContain('N'.repeat(100));
240
+ expect(output).toContain('_[1 additional highlight omitted to fit the context budget');
241
+ });
242
+ });
@@ -1,6 +1,113 @@
1
1
  import type * as t from './types';
2
2
  import { getDomainName, fileExtRegex } from './utils';
3
3
 
4
+ /** Default per-search budget for model-facing highlight content (chars). Hosts
5
+ * that know the context window (e.g. LibreChat) pass a window-relative value;
6
+ * this fixed fallback keeps standalone consumers bounded instead of dumping the
7
+ * full reranked content of every source into the prompt. */
8
+ const DEFAULT_MAX_LLM_OUTPUT_CHARS = 50000;
9
+
10
+ /** Minimum room (chars) worth filling with a truncated boundary highlight; below
11
+ * this we drop it whole rather than emit a useless sliver. */
12
+ const MIN_PARTIAL_HIGHLIGHT_CHARS = 200;
13
+
14
+ /** Resolves the per-search highlight budget from config, the
15
+ * `SEARCH_MAX_LLM_OUTPUT_CHARS` env var, or the default (50,000 chars). */
16
+ export function resolveMaxLLMOutputChars(maxOutputChars?: number): number {
17
+ if (maxOutputChars != null && maxOutputChars > 0) {
18
+ return maxOutputChars;
19
+ }
20
+ const envValue = Number(process.env.SEARCH_MAX_LLM_OUTPUT_CHARS);
21
+ if (Number.isFinite(envValue) && envValue > 0) {
22
+ return envValue;
23
+ }
24
+ return DEFAULT_MAX_LLM_OUTPUT_CHARS;
25
+ }
26
+
27
+ /** Inline citation markers embedded in highlight text, e.g. `(link#2 "Title")`.
28
+ * Mirrors the matcher in `highlights.ts` so truncation can tell which citations
29
+ * survive in a sliced prefix. */
30
+ const REFERENCE_MARKER_REGEX = /\((link|image|video)#(\d+)(?:\s+"[^"]*")?\)/g;
31
+
32
+ /** Builds the set of `type#originalIndex` keys whose complete citation marker
33
+ * appears in `text`, so references can be filtered to those still visible. */
34
+ function visibleReferenceKeys(text: string): Set<string> {
35
+ const keys = new Set<string>();
36
+ if (!text.includes('#')) {
37
+ return keys;
38
+ }
39
+ const regex = new RegExp(REFERENCE_MARKER_REGEX);
40
+ let match: RegExpExecArray | null;
41
+ while ((match = regex.exec(text)) !== null) {
42
+ keys.add(`${match[1]}#${parseInt(match[2], 10) - 1}`);
43
+ }
44
+ return keys;
45
+ }
46
+
47
+ /** Truncates a highlight to `maxLen` chars of (already-trimmed) text, keeping
48
+ * only the references whose markers survive in the kept prefix — markers in the
49
+ * cut tail would otherwise emit Core References for citations the model can no
50
+ * longer see, while a blanket drop would lose still-visible ones. */
51
+ function truncateHighlight(highlight: t.Highlight, text: string, maxLen: number): t.Highlight {
52
+ const prefix = text.slice(0, maxLen);
53
+ const truncated: t.Highlight = { score: highlight.score, text: `${prefix}\n…[truncated]` };
54
+ if (highlight.references != null && highlight.references.length > 0) {
55
+ const keys = visibleReferenceKeys(prefix);
56
+ const visible = highlight.references.filter((ref) => keys.has(`${ref.type}#${ref.originalIndex}`));
57
+ if (visible.length > 0) {
58
+ truncated.references = visible;
59
+ }
60
+ }
61
+ return truncated;
62
+ }
63
+
64
+ /** Bounds the highlight chunks — the dominant, unbounded part of search output —
65
+ * to `maxChars`, walking sources in relevance order (organic first, then news;
66
+ * highlights in their reranked order). Whole highlights are kept until the
67
+ * budget is hit, the boundary one is truncated if meaningful room remains, and
68
+ * every later highlight is dropped (relevance-ordered prefix). Blank highlights
69
+ * are skipped (never rendered, so never charged); a truncated highlight keeps
70
+ * only references whose markers survive in the kept prefix. Snippets/titles/URLs
71
+ * are left untouched (small, high-signal) and per-source `content` stays in the
72
+ * `WEB_SEARCH` artifact for citations. Mutates `results` in place; returns how
73
+ * many highlights were dropped or truncated (0 when everything fit). */
74
+ function trimHighlightsToBudget(results: t.SearchResultData, maxChars: number): number {
75
+ let used = 0;
76
+ let trimmed = 0;
77
+ const sections: (t.ValidSource[] | undefined)[] = [results.organic, results.topStories];
78
+ for (const sources of sections) {
79
+ if (sources == null) {
80
+ continue;
81
+ }
82
+ for (const source of sources) {
83
+ const highlights = source.highlights;
84
+ if (highlights == null || highlights.length === 0) {
85
+ continue;
86
+ }
87
+ const kept: t.Highlight[] = [];
88
+ for (const highlight of highlights) {
89
+ const text = highlight.text.trim();
90
+ if (text.length === 0) {
91
+ continue;
92
+ }
93
+ if (used + text.length <= maxChars) {
94
+ kept.push(highlight);
95
+ used += text.length;
96
+ continue;
97
+ }
98
+ const remaining = maxChars - used;
99
+ if (remaining >= MIN_PARTIAL_HIGHLIGHT_CHARS) {
100
+ kept.push(truncateHighlight(highlight, text, remaining));
101
+ }
102
+ used = maxChars;
103
+ trimmed++;
104
+ }
105
+ source.highlights = kept;
106
+ }
107
+ }
108
+ return trimmed;
109
+ }
110
+
4
111
  function addHighlightSection(): string[] {
5
112
  return ['\n## Highlights', ''];
6
113
  }
@@ -112,8 +219,15 @@ function formatSource(
112
219
 
113
220
  export function formatResultsForLLM(
114
221
  turn: number,
115
- results: t.SearchResultData
222
+ results: t.SearchResultData,
223
+ maxOutputChars?: number
116
224
  ): { output: string; references: t.ResultReference[] } {
225
+ /** Bound highlight content to the per-search budget before formatting */
226
+ const trimmedHighlights = trimHighlightsToBudget(
227
+ results,
228
+ resolveMaxLLMOutputChars(maxOutputChars)
229
+ );
230
+
117
231
  /** Array to collect all output lines */
118
232
  const outputLines: string[] = [];
119
233
 
@@ -243,8 +357,11 @@ export function formatResultsForLLM(
243
357
  outputLines.push(paaLines.join(''));
244
358
  }
245
359
 
246
- return {
247
- output: outputLines.join('\n').trim(),
248
- references,
249
- };
360
+ let output = outputLines.join('\n').trim();
361
+ if (trimmedHighlights > 0) {
362
+ output += `\n\n_[${trimmedHighlights} additional highlight${
363
+ trimmedHighlights === 1 ? '' : 's'
364
+ } omitted to fit the context budget; the cited sources contain the full content.]_`;
365
+ }
366
+ return { output, references };
250
367
  }
@@ -289,10 +289,12 @@ function createOnSearchResults({
289
289
  function createTool({
290
290
  schema,
291
291
  search,
292
+ maxOutputChars,
292
293
  onSearchResults: _onSearchResults,
293
294
  }: {
294
295
  schema: Record<string, unknown>;
295
296
  search: ReturnType<typeof createSearchProcessor>;
297
+ maxOutputChars?: number;
296
298
  onSearchResults: t.SearchToolConfig['onSearchResults'];
297
299
  }): DynamicStructuredTool {
298
300
  return tool(
@@ -313,7 +315,7 @@ function createTool({
313
315
  }),
314
316
  });
315
317
  const turn = runnableConfig.toolCall?.turn ?? 0;
316
- const { output, references } = formatResultsForLLM(turn, searchResult);
318
+ const { output, references } = formatResultsForLLM(turn, searchResult, maxOutputChars);
317
319
  const data: t.SearchResultData = { turn, ...searchResult, references };
318
320
  return [output, { [Constants.WEB_SEARCH]: data }];
319
321
  },
@@ -359,6 +361,7 @@ export const createSearchTool = (
359
361
  rerankerType = 'cohere',
360
362
  topResults = 5,
361
363
  maxContentLength,
364
+ maxOutputChars,
362
365
  strategies = ['no_extraction'],
363
366
  filterContent = true,
364
367
  safeSearch = 1,
@@ -483,6 +486,7 @@ export const createSearchTool = (
483
486
  return createTool({
484
487
  search,
485
488
  schema: toolSchema,
489
+ maxOutputChars,
486
490
  onSearchResults: _onSearchResults,
487
491
  });
488
492
  };
@@ -218,6 +218,13 @@ export interface SearchToolConfig
218
218
  ProcessSourcesConfig,
219
219
  FirecrawlConfig {
220
220
  tavilyScraperOptions?: TavilyScraperConfig;
221
+ /** Max chars of highlight content this tool feeds the MODEL per search (the
222
+ * dominant, otherwise-unbounded part of the output). Distinct from
223
+ * `maxContentLength`, which caps scraped/reranked content per source — full
224
+ * content always remains in the `WEB_SEARCH` artifact. Defaults to 50,000;
225
+ * also configurable via the `SEARCH_MAX_LLM_OUTPUT_CHARS` env var. Hosts that
226
+ * know the context window (e.g. LibreChat) pass a window-relative value. */
227
+ maxOutputChars?: number;
221
228
  logger?: Logger;
222
229
  safeSearch?: SafeSearchLevel;
223
230
  jinaApiKey?: string;