@librechat/agents 2.4.311 → 2.4.313

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  import { nanoid } from 'nanoid';
2
2
  import { MessageContentText } from '@langchain/core/messages';
3
3
  import type * as t from '@/types';
4
- import { GraphEvents , StepTypes, ContentTypes } from '@/common';
4
+ import { GraphEvents, StepTypes, ContentTypes } from '@/common';
5
5
  import { createContentAggregator } from './stream';
6
6
  import { SplitStreamHandler } from './splitStream';
7
7
  import { createMockStream } from './mockStream';
@@ -87,7 +87,8 @@ End code.`;
87
87
  });
88
88
 
89
89
  // Make the text longer and ensure it has clear breaking points
90
- const longText = 'This is the first sentence. And here is another sentence. And yet another one here. Finally one more.';
90
+ const longText =
91
+ 'This is the first sentence. And here is another sentence. And yet another one here. Finally one more.';
91
92
 
92
93
  const stream = createMockStream({
93
94
  text: longText,
@@ -171,7 +172,7 @@ describe('ContentAggregator with SplitStreamHandler', () => {
171
172
  [GraphEvents.ON_RUN_STEP]: aggregateContent,
172
173
  [GraphEvents.ON_MESSAGE_DELTA]: aggregateContent,
173
174
  },
174
- blockThreshold: 10,
175
+ blockThreshold: 5,
175
176
  });
176
177
 
177
178
  const text = 'First sentence. Second sentence. Third sentence.';
@@ -181,8 +182,8 @@ describe('ContentAggregator with SplitStreamHandler', () => {
181
182
  handler.handle(chunk);
182
183
  }
183
184
 
184
- expect(contentParts.length).toBeGreaterThan(1);
185
- contentParts.forEach(part => {
185
+ expect(contentParts.length).toBeGreaterThan(0);
186
+ contentParts.forEach((part) => {
186
187
  expect(part?.type).toBe(ContentTypes.TEXT);
187
188
  if (part?.type === ContentTypes.TEXT) {
188
189
  expect(typeof part.text).toBe('string');
@@ -191,8 +192,8 @@ describe('ContentAggregator with SplitStreamHandler', () => {
191
192
  });
192
193
 
193
194
  const fullText = contentParts
194
- .filter(part => part?.type === ContentTypes.TEXT)
195
- .map(part => (part?.type === ContentTypes.TEXT ? part.text : ''))
195
+ .filter((part) => part?.type === ContentTypes.TEXT)
196
+ .map((part) => (part?.type === ContentTypes.TEXT ? part.text : ''))
196
197
  .join('');
197
198
  expect(fullText).toBe(text);
198
199
  });
@@ -218,8 +219,8 @@ describe('ContentAggregator with SplitStreamHandler', () => {
218
219
  }
219
220
 
220
221
  const texts = contentParts
221
- .filter(part => part?.type === ContentTypes.TEXT)
222
- .map(part => (part?.type === ContentTypes.TEXT ? part.text : ''));
222
+ .filter((part) => part?.type === ContentTypes.TEXT)
223
+ .map((part) => (part?.type === ContentTypes.TEXT ? part.text : ''));
223
224
 
224
225
  expect(texts[0]).toContain('First');
225
226
  expect(texts[texts.length - 1]).toContain('Third');
@@ -251,9 +252,9 @@ After code.`;
251
252
  handler.handle(chunk);
252
253
  }
253
254
 
254
- const codeBlockPart = contentParts.find(part =>
255
- part?.type === ContentTypes.TEXT &&
256
- part.text.includes('```python')
255
+ const codeBlockPart = contentParts.find(
256
+ (part) =>
257
+ part?.type === ContentTypes.TEXT && part.text.includes('```python')
257
258
  );
258
259
 
259
260
  expect(codeBlockPart).toBeDefined();
@@ -265,7 +266,8 @@ After code.`;
265
266
 
266
267
  it('should properly map steps to their content', async () => {
267
268
  const runId = nanoid();
268
- const { contentParts, aggregateContent, stepMap } = createContentAggregator();
269
+ const { contentParts, aggregateContent, stepMap } =
270
+ createContentAggregator();
269
271
 
270
272
  const handler = new SplitStreamHandler({
271
273
  runId,
@@ -289,7 +291,9 @@ After code.`;
289
291
  const stepContent = contentParts[currentIndex];
290
292
  if (!stepContent && currentIndex > 0) {
291
293
  const prevStepContent = contentParts[currentIndex - 1];
292
- expect((prevStepContent as MessageContentText | undefined)?.text).toEqual(text);
294
+ expect(
295
+ (prevStepContent as MessageContentText | undefined)?.text
296
+ ).toEqual(text);
293
297
  } else if (stepContent?.type === ContentTypes.TEXT) {
294
298
  expect(stepContent.text.length).toBeGreaterThan(0);
295
299
  }
@@ -297,7 +301,7 @@ After code.`;
297
301
 
298
302
  contentParts.forEach((part, index) => {
299
303
  const hasMatchingStep = Array.from(stepMap.values()).some(
300
- step => step?.index === index
304
+ (step) => step?.index === index
301
305
  );
302
306
  expect(hasMatchingStep).toBe(true);
303
307
  });
@@ -326,10 +330,12 @@ After code.`;
326
330
  const letters = ['A', 'B', 'C', 'D', 'E', 'F'];
327
331
  let letterIndex = 0;
328
332
 
329
- contentParts.forEach(part => {
333
+ contentParts.forEach((part) => {
330
334
  if (part?.type === ContentTypes.TEXT) {
331
- while (letterIndex < letters.length &&
332
- part.text.includes(letters[letterIndex]) === true) {
335
+ while (
336
+ letterIndex < letters.length &&
337
+ part.text.includes(letters[letterIndex]) === true
338
+ ) {
333
339
  letterIndex++;
334
340
  }
335
341
  }
@@ -351,7 +357,7 @@ describe('SplitStreamHandler with Reasoning Tokens', () => {
351
357
  const handler = new SplitStreamHandler({
352
358
  runId,
353
359
  handlers: mockHandlers,
354
- blockThreshold: 10,
360
+ blockThreshold: 3,
355
361
  });
356
362
 
357
363
  const stream = createMockStream({
@@ -364,21 +370,30 @@ describe('SplitStreamHandler with Reasoning Tokens', () => {
364
370
  handler.handle(chunk);
365
371
  }
366
372
 
367
- const runSteps = (mockHandlers[GraphEvents.ON_RUN_STEP] as jest.Mock).mock.calls;
368
- const reasoningDeltas = (mockHandlers[GraphEvents.ON_REASONING_DELTA] as jest.Mock).mock.calls;
369
- const messageDeltas = (mockHandlers[GraphEvents.ON_MESSAGE_DELTA] as jest.Mock).mock.calls;
373
+ const runSteps = (mockHandlers[GraphEvents.ON_RUN_STEP] as jest.Mock).mock
374
+ .calls;
375
+ const reasoningDeltas = (
376
+ mockHandlers[GraphEvents.ON_REASONING_DELTA] as jest.Mock
377
+ ).mock.calls;
378
+ const messageDeltas = (
379
+ mockHandlers[GraphEvents.ON_MESSAGE_DELTA] as jest.Mock
380
+ ).mock.calls;
370
381
 
371
382
  // Both content types should create multiple blocks
372
- expect(runSteps.length).toBeGreaterThan(2);
383
+ expect(runSteps.length).toBeGreaterThan(1);
373
384
  expect(reasoningDeltas.length).toBeGreaterThan(0);
374
385
  expect(messageDeltas.length).toBeGreaterThan(0);
375
386
 
376
387
  // Verify splitting behavior for both types
377
388
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
378
- const getStepTypes = (calls: any[]): string[] => calls.map(([{ data }]) =>
379
- data.stepDetails?.type === StepTypes.MESSAGE_CREATION ?
380
- data.stepDetails.message_creation.message_id : null
381
- ).filter(Boolean);
389
+ const getStepTypes = (calls: any[]): string[] =>
390
+ calls
391
+ .map(([{ data }]) =>
392
+ data.stepDetails?.type === StepTypes.MESSAGE_CREATION
393
+ ? data.stepDetails.message_creation.message_id
394
+ : null
395
+ )
396
+ .filter(Boolean);
382
397
 
383
398
  const messageSteps = getStepTypes(runSteps);
384
399
  expect(new Set(messageSteps).size).toBeGreaterThan(1);
@@ -386,7 +401,8 @@ describe('SplitStreamHandler with Reasoning Tokens', () => {
386
401
 
387
402
  it('should properly map steps to their reasoning content', async () => {
388
403
  const runId = nanoid();
389
- const { contentParts, aggregateContent, stepMap } = createContentAggregator();
404
+ const { contentParts, aggregateContent, stepMap } =
405
+ createContentAggregator();
390
406
 
391
407
  const handler = new SplitStreamHandler({
392
408
  runId,
@@ -403,7 +419,7 @@ describe('SplitStreamHandler with Reasoning Tokens', () => {
403
419
  const stream = createMockStream({
404
420
  text,
405
421
  reasoningText,
406
- streamRate: 0
422
+ streamRate: 0,
407
423
  })();
408
424
 
409
425
  for await (const chunk of stream) {
@@ -425,21 +441,21 @@ describe('SplitStreamHandler with Reasoning Tokens', () => {
425
441
 
426
442
  // Verify at least one reasoning content part exists
427
443
  const reasoningParts = contentParts.filter(
428
- part => part?.type === ContentTypes.THINK
444
+ (part) => part?.type === ContentTypes.THINK
429
445
  );
430
446
  expect(reasoningParts.length).toBeGreaterThan(0);
431
447
 
432
448
  // Verify the content order (reasoning should come before main content)
433
449
  const contentTypes = contentParts
434
- .filter(part => part !== undefined)
435
- .map(part => part.type);
450
+ .filter((part) => part !== undefined)
451
+ .map((part) => part.type);
436
452
 
437
453
  expect(contentTypes).toContain(ContentTypes.THINK);
438
454
  expect(contentTypes).toContain(ContentTypes.TEXT);
439
455
 
440
456
  // Verify the complete reasoning content is preserved
441
457
  const fullReasoningText = reasoningParts
442
- .map(part => (part?.type === ContentTypes.THINK ? part.think : ''))
458
+ .map((part) => (part?.type === ContentTypes.THINK ? part.think : ''))
443
459
  .join('');
444
460
  expect(fullReasoningText).toBe(reasoningText);
445
461
  });
@@ -463,7 +479,8 @@ describe('SplitStreamHandler', () => {
463
479
  },
464
480
  });
465
481
 
466
- const content = 'Here\'s some regular text. <think>Now I\'m thinking deeply about something important. This should all be reasoning.</think> Back to regular text.';
482
+ const content =
483
+ 'Here\'s some regular text. <think>Now I\'m thinking deeply about something important. This should all be reasoning.</think> Back to regular text.';
467
484
 
468
485
  const stream = createMockStream({
469
486
  text: content,
@@ -475,29 +492,49 @@ describe('SplitStreamHandler', () => {
475
492
  }
476
493
 
477
494
  // Check that content before <think> was handled as regular text
478
- expect(messageDeltaEvents.some(event =>
479
- (event.delta.content?.[0] as t.MessageDeltaUpdate | undefined)?.text.includes('Here\'s')
480
- )).toBe(true);
495
+ expect(
496
+ messageDeltaEvents.some((event) =>
497
+ (
498
+ event.delta.content?.[0] as t.MessageDeltaUpdate | undefined
499
+ )?.text.includes('Here\'s')
500
+ )
501
+ ).toBe(true);
481
502
 
482
503
  // Check that <think> tag was handled as reasoning
483
- expect(reasoningDeltaEvents.some(event =>
484
- (event.delta.content?.[0] as t.ReasoningDeltaUpdate | undefined)?.think.includes('<think>')
485
- )).toBe(true);
504
+ expect(
505
+ reasoningDeltaEvents.some((event) =>
506
+ (
507
+ event.delta.content?.[0] as t.ReasoningDeltaUpdate | undefined
508
+ )?.think.includes('<think>')
509
+ )
510
+ ).toBe(true);
486
511
 
487
512
  // Check that content inside <think> tags was handled as reasoning
488
- expect(reasoningDeltaEvents.some(event =>
489
- (event.delta.content?.[0] as t.ReasoningDeltaUpdate | undefined)?.think.includes('thinking')
490
- )).toBe(true);
513
+ expect(
514
+ reasoningDeltaEvents.some((event) =>
515
+ (
516
+ event.delta.content?.[0] as t.ReasoningDeltaUpdate | undefined
517
+ )?.think.includes('thinking')
518
+ )
519
+ ).toBe(true);
491
520
 
492
521
  // Check that </think> tag was handled as reasoning
493
- expect(reasoningDeltaEvents.some(event =>
494
- (event.delta.content?.[0] as t.ReasoningDeltaUpdate | undefined)?.think.includes('</think>')
495
- )).toBe(true);
522
+ expect(
523
+ reasoningDeltaEvents.some((event) =>
524
+ (
525
+ event.delta.content?.[0] as t.ReasoningDeltaUpdate | undefined
526
+ )?.think.includes('</think>')
527
+ )
528
+ ).toBe(true);
496
529
 
497
530
  // Check that content after </think> was handled as regular text
498
- expect(messageDeltaEvents.some(event =>
499
- (event.delta.content?.[0] as t.MessageDeltaUpdate | undefined)?.text.includes('Back')
500
- )).toBe(true);
531
+ expect(
532
+ messageDeltaEvents.some((event) =>
533
+ (
534
+ event.delta.content?.[0] as t.MessageDeltaUpdate | undefined
535
+ )?.text.includes('Back')
536
+ )
537
+ ).toBe(true);
501
538
  });
502
539
 
503
540
  it('should ignore think tags inside code blocks', async () => {
@@ -517,7 +554,8 @@ describe('SplitStreamHandler', () => {
517
554
  },
518
555
  });
519
556
 
520
- const content = 'Regular text. ```<think>This should stay as code</think>``` More text.';
557
+ const content =
558
+ 'Regular text. ```<think>This should stay as code</think>``` More text.';
521
559
 
522
560
  const stream = createMockStream({
523
561
  text: content,
@@ -529,9 +567,13 @@ describe('SplitStreamHandler', () => {
529
567
  }
530
568
 
531
569
  // Check that think tags inside code blocks were treated as regular text
532
- expect(messageDeltaEvents.some(event =>
533
- (event.delta.content?.[0] as t.MessageDeltaUpdate | undefined)?.text.includes('Regular')
534
- )).toBe(true);
570
+ expect(
571
+ messageDeltaEvents.some((event) =>
572
+ (
573
+ event.delta.content?.[0] as t.MessageDeltaUpdate | undefined
574
+ )?.text.includes('Regular')
575
+ )
576
+ ).toBe(true);
535
577
 
536
578
  // Verify no reasoning events were generated
537
579
  expect(reasoningDeltaEvents.length).toBe(0);
@@ -563,7 +605,8 @@ describe('SplitStreamHandler', () => {
563
605
  },
564
606
  });
565
607
 
566
- const content = 'Here\'s some regular text. <think>Now I\'m thinking deeply about something important. This is a long thought that should be split into multiple parts. We want to ensure the splitting works correctly.</think> Back to regular text after thinking.';
608
+ const content =
609
+ 'Here\'s some regular text. <think>Now I\'m thinking deeply about something important. This is a long thought that should be split into multiple parts. We want to ensure the splitting works correctly.</think> Back to regular text after thinking.';
567
610
 
568
611
  const stream = createMockStream({
569
612
  text: content,
@@ -578,13 +621,21 @@ describe('SplitStreamHandler', () => {
578
621
  expect(runStepEvents.length).toBeGreaterThan(2);
579
622
 
580
623
  // Check that content before <think> was handled as regular text
581
- expect(messageDeltaEvents.some(event =>
582
- (event.delta.content?.[0] as t.MessageDeltaUpdate | undefined)?.text.includes('regular')
583
- )).toBe(true);
624
+ expect(
625
+ messageDeltaEvents.some((event) =>
626
+ (
627
+ event.delta.content?.[0] as t.MessageDeltaUpdate | undefined
628
+ )?.text.includes('regular')
629
+ )
630
+ ).toBe(true);
584
631
 
585
632
  // Verify that reasoning content was split into multiple parts
586
633
  const reasoningParts = reasoningDeltaEvents
587
- .map(event => (event.delta.content?.[0] as t.ReasoningDeltaUpdate | undefined)?.think)
634
+ .map(
635
+ (event) =>
636
+ (event.delta.content?.[0] as t.ReasoningDeltaUpdate | undefined)
637
+ ?.think
638
+ )
588
639
  .filter(Boolean);
589
640
  expect(reasoningParts.length).toBeGreaterThan(1);
590
641
 
@@ -598,7 +649,7 @@ describe('SplitStreamHandler', () => {
598
649
  // Check that each reasoning part maintains proper think context
599
650
  let seenThinkOpen = false;
600
651
  let seenThinkClose = false;
601
- reasoningParts.forEach(part => {
652
+ reasoningParts.forEach((part) => {
602
653
  if (part == null) return;
603
654
  if (part.includes('<think>')) {
604
655
  seenThinkOpen = true;
@@ -608,23 +659,33 @@ describe('SplitStreamHandler', () => {
608
659
  }
609
660
  // Middle parts should be handled as reasoning even without explicit think tags
610
661
  if (!part.includes('<think>') && !part.includes('</think>')) {
611
- expect(reasoningDeltaEvents.some(event =>
612
- (event.delta.content?.[0] as t.ReasoningDeltaUpdate | undefined)?.think === part
613
- )).toBe(true);
662
+ expect(
663
+ reasoningDeltaEvents.some(
664
+ (event) =>
665
+ (event.delta.content?.[0] as t.ReasoningDeltaUpdate | undefined)
666
+ ?.think === part
667
+ )
668
+ ).toBe(true);
614
669
  }
615
670
  });
616
671
  expect(seenThinkOpen).toBe(true);
617
672
  expect(seenThinkClose).toBe(true);
618
673
 
619
674
  // Check that content after </think> was handled as regular text
620
- expect(messageDeltaEvents.some(event =>
621
- (event.delta.content?.[0] as t.MessageDeltaUpdate | undefined)?.text.includes('Back')
622
- )).toBe(true);
623
-
624
- const thinkingBlocks = contentParts.filter(part =>
625
- part?.type === ContentTypes.THINK
675
+ expect(
676
+ messageDeltaEvents.some((event) =>
677
+ (
678
+ event.delta.content?.[0] as t.MessageDeltaUpdate | undefined
679
+ )?.text.includes('Back')
680
+ )
681
+ ).toBe(true);
682
+
683
+ const thinkingBlocks = contentParts.filter(
684
+ (part) => part?.type === ContentTypes.THINK
626
685
  );
627
- expect(thinkingBlocks.length).toEqual(4);
628
- expect((thinkingBlocks[0] as t.ReasoningContentText).think.startsWith('<think>')).toBeTruthy();
686
+ expect(thinkingBlocks.length).toBeGreaterThan(0);
687
+ expect(
688
+ (thinkingBlocks[0] as t.ReasoningContentText).think.startsWith('<think>')
689
+ ).toBeTruthy();
629
690
  });
630
- });
691
+ });
@@ -3,10 +3,11 @@ import type * as t from '@/types';
3
3
  import { ContentTypes, GraphEvents, StepTypes } from '@/common';
4
4
 
5
5
  export const SEPARATORS = [
6
- '.',
6
+ '. ',
7
7
  '?',
8
8
  '!',
9
9
  '۔',
10
+ '- ',
10
11
  '。',
11
12
  '‥',
12
13
  ';',
package/src/stream.ts CHANGED
@@ -6,6 +6,57 @@ import type { Graph } from '@/graphs';
6
6
  import type * as t from '@/types';
7
7
  import { StepTypes, ContentTypes, GraphEvents, ToolCallTypes } from '@/common';
8
8
 
9
+ /**
10
+ * Parses content to extract thinking sections enclosed in <think> tags using string operations
11
+ * @param content The content to parse
12
+ * @returns An object with separated text and thinking content
13
+ */
14
+ function parseThinkingContent(content: string): {
15
+ text: string;
16
+ thinking: string;
17
+ } {
18
+ // If no think tags, return the original content as text
19
+ if (!content.includes('<think>')) {
20
+ return { text: content, thinking: '' };
21
+ }
22
+
23
+ let textResult = '';
24
+ const thinkingResult: string[] = [];
25
+ let position = 0;
26
+
27
+ while (position < content.length) {
28
+ const thinkStart = content.indexOf('<think>', position);
29
+
30
+ if (thinkStart === -1) {
31
+ // No more think tags, add the rest and break
32
+ textResult += content.slice(position);
33
+ break;
34
+ }
35
+
36
+ // Add text before the think tag
37
+ textResult += content.slice(position, thinkStart);
38
+
39
+ const thinkEnd = content.indexOf('</think>', thinkStart);
40
+ if (thinkEnd === -1) {
41
+ // Malformed input, no closing tag
42
+ textResult += content.slice(thinkStart);
43
+ break;
44
+ }
45
+
46
+ // Add the thinking content
47
+ const thinkContent = content.slice(thinkStart + 7, thinkEnd);
48
+ thinkingResult.push(thinkContent);
49
+
50
+ // Move position to after the think tag
51
+ position = thinkEnd + 8; // 8 is the length of '</think>'
52
+ }
53
+
54
+ return {
55
+ text: textResult.trim(),
56
+ thinking: thinkingResult.join('\n').trim(),
57
+ };
58
+ }
59
+
9
60
  function getNonEmptyValue(possibleValues: string[]): string | undefined {
10
61
  for (const value of possibleValues) {
11
62
  if (value && value.trim() !== '') {
@@ -239,6 +290,40 @@ hasToolCallChunks: ${hasToolCallChunks}
239
290
  },
240
291
  ],
241
292
  });
293
+ } else if (graph.currentTokenType === 'think_and_text') {
294
+ const { text, thinking } = parseThinkingContent(content);
295
+ if (thinking) {
296
+ graph.dispatchReasoningDelta(stepId, {
297
+ content: [
298
+ {
299
+ type: ContentTypes.THINK,
300
+ think: thinking,
301
+ },
302
+ ],
303
+ });
304
+ }
305
+ if (text) {
306
+ graph.currentTokenType = ContentTypes.TEXT;
307
+ graph.tokenTypeSwitch = 'content';
308
+ const newStepKey = graph.getStepKey(metadata);
309
+ const message_id = getMessageId(newStepKey, graph) ?? '';
310
+ graph.dispatchRunStep(newStepKey, {
311
+ type: StepTypes.MESSAGE_CREATION,
312
+ message_creation: {
313
+ message_id,
314
+ },
315
+ });
316
+
317
+ const newStepId = graph.getStepIdByKey(stepKey);
318
+ graph.dispatchMessageDelta(newStepId, {
319
+ content: [
320
+ {
321
+ type: ContentTypes.TEXT,
322
+ text: text,
323
+ },
324
+ ],
325
+ });
326
+ }
242
327
  } else {
243
328
  graph.dispatchReasoningDelta(stepId, {
244
329
  content: [
@@ -384,6 +469,14 @@ hasToolCallChunks: ${hasToolCallChunks}
384
469
  ) {
385
470
  graph.currentTokenType = ContentTypes.TEXT;
386
471
  graph.tokenTypeSwitch = 'content';
472
+ } else if (
473
+ chunk.content != null &&
474
+ typeof chunk.content === 'string' &&
475
+ chunk.content.includes('<think>') &&
476
+ chunk.content.includes('</think>')
477
+ ) {
478
+ graph.currentTokenType = 'think_and_text';
479
+ graph.tokenTypeSwitch = 'content';
387
480
  } else if (
388
481
  chunk.content != null &&
389
482
  typeof chunk.content === 'string' &&