@librechat/agents 2.4.63 → 2.4.65

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.
@@ -0,0 +1,453 @@
1
+ /* eslint-disable no-console */
2
+
3
+ import { config } from 'dotenv';
4
+ config();
5
+ import type { LLMResult } from '@langchain/core/outputs';
6
+ import { getLLMConfig } from '@/utils/llmConfig';
7
+ import { Providers, TitleMethod } from '@/common';
8
+ import { Run } from '@/run';
9
+ import type * as t from '@/types';
10
+
11
+ /**
12
+ * Helper to force garbage collection if available
13
+ * Note: This requires Node.js to be run with --expose-gc flag
14
+ */
15
+ function forceGC(): void {
16
+ if (global.gc) {
17
+ global.gc();
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Helper to wait for potential async cleanup
23
+ */
24
+ async function waitForCleanup(ms: number = 100): Promise<void> {
25
+ await new Promise((resolve) => setTimeout(resolve, ms));
26
+ }
27
+
28
+ /**
29
+ * Factory function to create a callback handler that captures LLM results
30
+ * without creating memory leaks through closures
31
+ */
32
+ function createLLMResultCapture(): {
33
+ callback: {
34
+ handleLLMEnd(data: LLMResult): void;
35
+ };
36
+ getResult(): LLMResult | undefined;
37
+ } {
38
+ let capturedResult: LLMResult | undefined;
39
+
40
+ return {
41
+ callback: {
42
+ handleLLMEnd(data: LLMResult): void {
43
+ capturedResult = data;
44
+ },
45
+ },
46
+ getResult(): LLMResult | undefined {
47
+ return capturedResult;
48
+ },
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Test to ensure title generation doesn't create memory leaks
54
+ * Note: These tests verify that the title generation functions
55
+ * properly clean up after themselves and don't retain references
56
+ */
57
+ describe('Title Generation Memory Leak Tests', () => {
58
+ jest.setTimeout(120000); // 2 minutes timeout for memory tests
59
+
60
+ const providers = [Providers.OPENAI];
61
+
62
+ providers.forEach((provider) => {
63
+ describe(`${provider} Memory Leak Tests`, () => {
64
+ let run: Run<t.IState>;
65
+
66
+ beforeEach(async () => {
67
+ const llmConfig = getLLMConfig(provider);
68
+ run = await Run.create<t.IState>({
69
+ runId: `memory-test-${Date.now()}`,
70
+ graphConfig: {
71
+ type: 'standard',
72
+ llmConfig,
73
+ tools: [],
74
+ instructions: 'You are a helpful assistant.',
75
+ },
76
+ returnContent: true,
77
+ });
78
+ });
79
+
80
+ test('should not leak memory when using callback factory', async () => {
81
+ const weakRefs: WeakRef<LLMResult>[] = [];
82
+ const iterations = 5;
83
+
84
+ // Run multiple title generations with callbacks
85
+ for (let i = 0; i < iterations; i++) {
86
+ const resultCapture = createLLMResultCapture();
87
+
88
+ const result = await run.generateTitle({
89
+ provider,
90
+ inputText: `Test message ${i}`,
91
+ titleMethod: TitleMethod.STRUCTURED,
92
+ contentParts: [{ type: 'text' as const, text: `Response ${i}` }],
93
+ chainOptions: {
94
+ callbacks: [resultCapture.callback],
95
+ },
96
+ });
97
+
98
+ expect(result).toBeDefined();
99
+ expect(result.title).toBeDefined();
100
+ expect(result.language).toBeDefined();
101
+
102
+ const capturedResult = resultCapture.getResult();
103
+ if (capturedResult) {
104
+ weakRefs.push(new WeakRef(capturedResult));
105
+ }
106
+ }
107
+
108
+ // Clear references and wait for async operations
109
+ await waitForCleanup(3000);
110
+ forceGC();
111
+ await waitForCleanup(3000);
112
+ forceGC(); // Run GC twice to be thorough
113
+ await waitForCleanup(3000);
114
+
115
+ // Check that most LLMResult objects have been garbage collected
116
+ let aliveCount = 0;
117
+ weakRefs.forEach((ref) => {
118
+ if (ref.deref() !== undefined) {
119
+ aliveCount++;
120
+ }
121
+ });
122
+
123
+ // We expect most references to be collected
124
+ // LangChain may cache 0-2 results temporarily for optimization
125
+ // The exact number can vary based on timing and internal optimizations
126
+ expect(aliveCount).toBeLessThanOrEqual(2);
127
+ console.log(
128
+ `Memory leak test: ${aliveCount} out of ${iterations} references still alive`
129
+ );
130
+ });
131
+
132
+ test('should not accumulate callbacks across multiple invocations', async () => {
133
+ const callbackCounts: number[] = [];
134
+
135
+ // Run multiple title generations with the same callback pattern
136
+ for (let i = 0; i < 3; i++) {
137
+ let callbackInvocations = 0;
138
+
139
+ const trackingCallback = {
140
+ handleLLMEnd: (_data: LLMResult): void => {
141
+ callbackInvocations++;
142
+ },
143
+ };
144
+
145
+ await run.generateTitle({
146
+ provider,
147
+ inputText: `Test message ${i}`,
148
+ titleMethod: TitleMethod.STRUCTURED,
149
+ contentParts: [{ type: 'text' as const, text: `Response ${i}` }],
150
+ chainOptions: {
151
+ callbacks: [trackingCallback],
152
+ },
153
+ });
154
+
155
+ // Each generation should trigger the callback exactly once
156
+ callbackCounts.push(callbackInvocations);
157
+ }
158
+
159
+ // Verify each invocation triggered the callback exactly once
160
+ expect(callbackCounts).toEqual([1, 1, 1]);
161
+ });
162
+
163
+ test('should isolate callback state between concurrent invocations', async () => {
164
+ const captures: ReturnType<typeof createLLMResultCapture>[] = [];
165
+
166
+ // Run multiple concurrent title generations
167
+ const promises = Array.from({ length: 5 }, (_, i) => {
168
+ const capture = createLLMResultCapture();
169
+ captures.push(capture);
170
+
171
+ return run.generateTitle({
172
+ provider,
173
+ inputText: `Concurrent test ${i}`,
174
+ titleMethod: TitleMethod.STRUCTURED,
175
+ contentParts: [
176
+ { type: 'text' as const, text: `Concurrent response ${i}` },
177
+ ],
178
+ chainOptions: {
179
+ callbacks: [capture.callback],
180
+ },
181
+ });
182
+ });
183
+
184
+ const results = await Promise.all(promises);
185
+
186
+ // All results should be defined and have titles
187
+ results.forEach((result, i) => {
188
+ expect(result).toBeDefined();
189
+ expect(result.title).toBeDefined();
190
+ expect(result.language).toBeDefined();
191
+
192
+ // Each capture should have its own result
193
+ const capturedResult = captures[i].getResult();
194
+ expect(capturedResult).toBeDefined();
195
+ expect(capturedResult?.generations).toBeDefined();
196
+ });
197
+
198
+ // Verify all captured results are unique instances
199
+ const capturedResults = captures
200
+ .map((c) => c.getResult())
201
+ .filter((r) => r !== undefined);
202
+ const uniqueResults = new Set(capturedResults);
203
+ expect(uniqueResults.size).toBe(capturedResults.length);
204
+ });
205
+
206
+ test('should handle completion method with callbacks properly', async () => {
207
+ const resultCapture = createLLMResultCapture();
208
+
209
+ const result = await run.generateTitle({
210
+ provider,
211
+ inputText: 'Completion test',
212
+ titleMethod: TitleMethod.COMPLETION,
213
+ contentParts: [
214
+ { type: 'text' as const, text: 'Response for completion' },
215
+ ],
216
+ chainOptions: {
217
+ callbacks: [resultCapture.callback],
218
+ },
219
+ });
220
+
221
+ expect(result).toBeDefined();
222
+ expect(result.title).toBeDefined();
223
+ // Completion method doesn't return language
224
+ expect(result.language).toBeUndefined();
225
+
226
+ const capturedResult = resultCapture.getResult();
227
+ expect(capturedResult).toBeDefined();
228
+ expect(capturedResult?.generations).toBeDefined();
229
+ });
230
+
231
+ test('factory function should create isolated instances', async () => {
232
+ // Create multiple captures
233
+ const captures = Array.from({ length: 3 }, () =>
234
+ createLLMResultCapture()
235
+ );
236
+
237
+ // Simulate different LLM results
238
+ const mockResults: LLMResult[] = [
239
+ { generations: [[{ text: 'Result 1' }]], llmOutput: {} },
240
+ { generations: [[{ text: 'Result 2' }]], llmOutput: {} },
241
+ { generations: [[{ text: 'Result 3' }]], llmOutput: {} },
242
+ ];
243
+
244
+ // Each capture should store its own result
245
+ captures.forEach((capture, i) => {
246
+ capture.callback.handleLLMEnd(mockResults[i]);
247
+ });
248
+
249
+ // Verify each capture has its own isolated result
250
+ captures.forEach((capture, i) => {
251
+ const result = capture.getResult();
252
+ expect(result).toBe(mockResults[i]);
253
+ expect(result?.generations[0][0].text).toBe(`Result ${i + 1}`);
254
+ });
255
+ });
256
+
257
+ test('diagnostic: check if creating new Run instances helps', async () => {
258
+ const weakRefs: WeakRef<LLMResult>[] = [];
259
+ const iterations = 5;
260
+
261
+ // Create a new Run instance for each iteration
262
+ for (let i = 0; i < iterations; i++) {
263
+ const llmConfig = getLLMConfig(provider);
264
+ const newRun = await Run.create<t.IState>({
265
+ runId: `memory-test-${Date.now()}-${i}`,
266
+ graphConfig: {
267
+ type: 'standard',
268
+ llmConfig,
269
+ tools: [],
270
+ instructions: 'You are a helpful assistant.',
271
+ },
272
+ returnContent: true,
273
+ });
274
+
275
+ const resultCapture = createLLMResultCapture();
276
+
277
+ await newRun.generateTitle({
278
+ provider,
279
+ inputText: `Test message ${i}`,
280
+ titleMethod: TitleMethod.STRUCTURED,
281
+ contentParts: [{ type: 'text' as const, text: `Response ${i}` }],
282
+ chainOptions: {
283
+ callbacks: [resultCapture.callback],
284
+ },
285
+ });
286
+
287
+ const capturedResult = resultCapture.getResult();
288
+ if (capturedResult) {
289
+ weakRefs.push(new WeakRef(capturedResult));
290
+ }
291
+ }
292
+
293
+ // Clear references and wait for async operations
294
+ await waitForCleanup(200);
295
+ forceGC();
296
+ await waitForCleanup(200);
297
+ forceGC();
298
+ await waitForCleanup(100);
299
+
300
+ // Check how many references are still alive
301
+ let aliveCount = 0;
302
+ weakRefs.forEach((ref) => {
303
+ if (ref.deref() !== undefined) {
304
+ aliveCount++;
305
+ }
306
+ });
307
+
308
+ console.log(
309
+ `Diagnostic (new Run instances): ${aliveCount} out of ${iterations} references still alive`
310
+ );
311
+
312
+ // Hypothesis: If it's still 2, it's LangChain global cache
313
+ // If it's 5 or more, it's per-instance caching
314
+ expect(aliveCount).toBeLessThanOrEqual(2);
315
+ });
316
+
317
+ test('memory retention patterns with different scenarios', async () => {
318
+ const scenarios = [
319
+ {
320
+ iterations: 3,
321
+ waitTime: 100,
322
+ description: '3 iterations, short wait',
323
+ },
324
+ {
325
+ iterations: 5,
326
+ waitTime: 200,
327
+ description: '5 iterations, medium wait',
328
+ },
329
+ {
330
+ iterations: 10,
331
+ waitTime: 300,
332
+ description: '10 iterations, long wait',
333
+ },
334
+ ];
335
+
336
+ for (const scenario of scenarios) {
337
+ const weakRefs: WeakRef<LLMResult>[] = [];
338
+
339
+ // Run title generations
340
+ for (let i = 0; i < scenario.iterations; i++) {
341
+ const resultCapture = createLLMResultCapture();
342
+
343
+ await run.generateTitle({
344
+ provider,
345
+ inputText: `Test message ${i}`,
346
+ titleMethod: TitleMethod.STRUCTURED,
347
+ contentParts: [{ type: 'text' as const, text: `Response ${i}` }],
348
+ chainOptions: {
349
+ callbacks: [resultCapture.callback],
350
+ },
351
+ });
352
+
353
+ const capturedResult = resultCapture.getResult();
354
+ if (capturedResult) {
355
+ weakRefs.push(new WeakRef(capturedResult));
356
+ }
357
+ }
358
+
359
+ // Multiple cleanup cycles with increasing wait times
360
+ await waitForCleanup(scenario.waitTime);
361
+ forceGC();
362
+ await waitForCleanup(scenario.waitTime);
363
+ forceGC();
364
+ await waitForCleanup(scenario.waitTime / 2);
365
+
366
+ // Count alive references
367
+ let aliveCount = 0;
368
+ weakRefs.forEach((ref) => {
369
+ if (ref.deref() !== undefined) {
370
+ aliveCount++;
371
+ }
372
+ });
373
+
374
+ console.log(
375
+ `${scenario.description}: ${aliveCount} out of ${scenario.iterations} references still alive`
376
+ );
377
+
378
+ // Expect 0-2 references regardless of iteration count
379
+ expect(aliveCount).toBeLessThanOrEqual(2);
380
+ }
381
+ });
382
+
383
+ test('should properly handle skipLanguage option with callbacks', async () => {
384
+ const resultCapture = createLLMResultCapture();
385
+
386
+ const result = await run.generateTitle({
387
+ provider,
388
+ inputText: 'Skip language test',
389
+ titleMethod: TitleMethod.STRUCTURED,
390
+ contentParts: [{ type: 'text' as const, text: 'Response' }],
391
+ skipLanguage: true,
392
+ chainOptions: {
393
+ callbacks: [resultCapture.callback],
394
+ },
395
+ });
396
+
397
+ expect(result).toBeDefined();
398
+ expect(result.title).toBeDefined();
399
+ // When skipLanguage is true, language should not be returned
400
+ expect(result.language).toBeUndefined();
401
+
402
+ const capturedResult = resultCapture.getResult();
403
+ expect(capturedResult).toBeDefined();
404
+ });
405
+ });
406
+ });
407
+
408
+ test('should handle errors gracefully', async () => {
409
+ const llmConfig = getLLMConfig(Providers.OPENAI);
410
+
411
+ // Create a run with invalid configuration to trigger errors
412
+ const run = await Run.create<t.IState>({
413
+ runId: 'error-test',
414
+ graphConfig: {
415
+ type: 'standard',
416
+ llmConfig: {
417
+ ...llmConfig,
418
+ apiKey: 'invalid-key', // This will cause API errors
419
+ },
420
+ tools: [],
421
+ instructions: 'Test',
422
+ },
423
+ returnContent: true,
424
+ });
425
+
426
+ // Attempt multiple failing title generations
427
+ for (let i = 0; i < 3; i++) {
428
+ try {
429
+ const resultCapture = createLLMResultCapture();
430
+
431
+ await run.generateTitle({
432
+ provider: Providers.OPENAI,
433
+ inputText: `Error test ${i}`,
434
+ titleMethod: TitleMethod.STRUCTURED,
435
+ contentParts: [{ type: 'text' as const, text: `Response ${i}` }],
436
+ chainOptions: {
437
+ callbacks: [resultCapture.callback],
438
+ },
439
+ });
440
+
441
+ // Should not reach here
442
+ fail('Expected error to be thrown');
443
+ } catch (error) {
444
+ // Expected to fail due to invalid API key
445
+ console.log(
446
+ `Expected error ${i}:`,
447
+ error instanceof Error ? error.message : String(error)
448
+ );
449
+ expect(error).toBeDefined();
450
+ }
451
+ }
452
+ });
453
+ });
package/src/types/run.ts CHANGED
@@ -33,6 +33,8 @@ export type RunTitleOptions = {
33
33
  clientOptions?: l.ClientOptions;
34
34
  chainOptions?: Partial<RunnableConfig> | undefined;
35
35
  omitOptions?: Set<string>;
36
+ titleMethod?: e.TitleMethod;
37
+ convoPromptTemplate?: string;
36
38
  };
37
39
 
38
40
  export interface AgentStateChannels {
@@ -1,8 +1,9 @@
1
1
  import { z } from 'zod';
2
- import { ChatPromptTemplate } from '@langchain/core/prompts';
3
2
  import { RunnableLambda } from '@langchain/core/runnables';
4
- import type { Runnable } from '@langchain/core/runnables';
5
- import * as t from '@/types';
3
+ import { ChatPromptTemplate } from '@langchain/core/prompts';
4
+ import type { Runnable, RunnableConfig } from '@langchain/core/runnables';
5
+ import type * as t from '@/types';
6
+ import { ContentTypes } from '@/common';
6
7
 
7
8
  const defaultTitlePrompt = `Analyze this conversation and provide:
8
9
  1. The detected language of the conversation
@@ -44,20 +45,29 @@ export const createTitleRunnable = async (
44
45
  );
45
46
 
46
47
  return new RunnableLambda({
47
- func: async (input: {
48
- convo: string;
49
- inputText: string;
50
- skipLanguage: boolean;
51
- }): Promise<{ language: string; title: string } | { title: string }> => {
48
+ func: async (
49
+ input: {
50
+ convo: string;
51
+ inputText: string;
52
+ skipLanguage: boolean;
53
+ },
54
+ config?: Partial<RunnableConfig>
55
+ ): Promise<{ language: string; title: string } | { title: string }> => {
52
56
  if (input.skipLanguage) {
53
- return (await titlePrompt.pipe(titleLLM).invoke({
54
- convo: input.convo,
55
- })) as { title: string };
57
+ return (await titlePrompt.pipe(titleLLM).invoke(
58
+ {
59
+ convo: input.convo,
60
+ },
61
+ config
62
+ )) as { title: string };
56
63
  }
57
64
 
58
- const result = (await titlePrompt.pipe(combinedLLM).invoke({
59
- convo: input.convo,
60
- })) as { language: string; title: string } | undefined;
65
+ const result = (await titlePrompt.pipe(combinedLLM).invoke(
66
+ {
67
+ convo: input.convo,
68
+ },
69
+ config
70
+ )) as { language: string; title: string } | undefined;
61
71
 
62
72
  return {
63
73
  language: result?.language ?? 'English',
@@ -66,3 +76,50 @@ export const createTitleRunnable = async (
66
76
  },
67
77
  });
68
78
  };
79
+
80
+ const defaultCompletionPrompt = `Provide a concise, 5-word-or-less title for the conversation, using its same language, with no punctuation. Apply title case conventions appropriate for the language. Never directly mention the language name or the word "title" and only return the title itself.
81
+
82
+ Conversation:
83
+ {convo}`;
84
+
85
+ export const createCompletionTitleRunnable = async (
86
+ model: t.ChatModelInstance,
87
+ titlePrompt?: string
88
+ ): Promise<Runnable> => {
89
+ const completionPrompt = ChatPromptTemplate.fromTemplate(
90
+ titlePrompt ?? defaultCompletionPrompt
91
+ );
92
+
93
+ return new RunnableLambda({
94
+ func: async (
95
+ input: {
96
+ convo: string;
97
+ inputText: string;
98
+ skipLanguage: boolean;
99
+ },
100
+ config?: Partial<RunnableConfig>
101
+ ): Promise<{ title: string }> => {
102
+ const promptOutput = await completionPrompt.invoke({
103
+ convo: input.convo,
104
+ });
105
+
106
+ const response = await model.invoke(promptOutput, config);
107
+ let content = '';
108
+ if (typeof response.content === 'string') {
109
+ content = response.content;
110
+ } else if (Array.isArray(response.content)) {
111
+ content = response.content
112
+ .filter(
113
+ (part): part is { type: ContentTypes.TEXT; text: string } =>
114
+ part.type === ContentTypes.TEXT
115
+ )
116
+ .map((part) => part.text)
117
+ .join('');
118
+ }
119
+ const title = content.trim();
120
+ return {
121
+ title,
122
+ };
123
+ },
124
+ });
125
+ };