@librechat/agents 3.2.0 → 3.2.1

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.
@@ -8,9 +8,17 @@ import {
8
8
  } from '@langchain/core/messages';
9
9
  import type { ToolCall } from '@langchain/core/messages/tool';
10
10
  import type * as t from '@/types';
11
- import { Providers } from '@/common';
11
+ import { ContentTypes, Providers } from '@/common';
12
12
  import { toLangChainContent } from './langchain';
13
13
 
14
+ type ReasoningSummary = { summary?: Array<{ text?: string }> };
15
+ type ReasoningDetail = { type?: string; text?: string };
16
+ type ReasoningAdditionalKwargs = {
17
+ reasoning_content?: string | Partial<ReasoningSummary> | null;
18
+ reasoning?: string | Partial<ReasoningSummary> | null;
19
+ reasoning_details?: ReasoningDetail[] | null;
20
+ };
21
+
14
22
  export function getConverseOverrideMessage({
15
23
  userMessage,
16
24
  lastMessageX,
@@ -143,6 +151,75 @@ function reduceBlocks(blocks: ContentBlock[]): ContentBlock[] {
143
151
  return reduced;
144
152
  }
145
153
 
154
+ function getReasoningText(
155
+ value: string | Partial<ReasoningSummary> | null | undefined
156
+ ): string | undefined {
157
+ if (typeof value === 'string') {
158
+ return value !== '' ? value : undefined;
159
+ }
160
+ const summaryText = value?.summary
161
+ ?.map((summary) => summary.text ?? '')
162
+ .filter((text) => text !== '')
163
+ .join('');
164
+ return summaryText != null && summaryText !== '' ? summaryText : undefined;
165
+ }
166
+
167
+ function getReasoningDetailsText(
168
+ value: ReasoningDetail[] | null | undefined
169
+ ): string | undefined {
170
+ if (!Array.isArray(value)) {
171
+ return undefined;
172
+ }
173
+ const reasoningText = value
174
+ .filter((detail) => detail.type === 'reasoning.text')
175
+ .map((detail) => detail.text ?? '')
176
+ .filter((text) => text !== '')
177
+ .join('');
178
+ return reasoningText !== '' ? reasoningText : undefined;
179
+ }
180
+
181
+ function getAdditionalReasoningContent(
182
+ message: BaseMessage
183
+ ): string | undefined {
184
+ const additionalKwargs =
185
+ message.additional_kwargs as ReasoningAdditionalKwargs | undefined;
186
+ if (additionalKwargs == null) {
187
+ return undefined;
188
+ }
189
+
190
+ const reasoningContent = getReasoningText(
191
+ additionalKwargs.reasoning_content
192
+ );
193
+ if (reasoningContent != null) {
194
+ return reasoningContent;
195
+ }
196
+
197
+ const reasoning = getReasoningText(additionalKwargs.reasoning);
198
+ if (reasoning != null) {
199
+ return reasoning;
200
+ }
201
+
202
+ return getReasoningDetailsText(additionalKwargs.reasoning_details);
203
+ }
204
+
205
+ function hasReasoningContent(content: BaseMessage['content']): boolean {
206
+ if (!Array.isArray(content)) {
207
+ return false;
208
+ }
209
+ return content.some((item) => {
210
+ if (typeof item !== 'object' || !('type' in item)) {
211
+ return false;
212
+ }
213
+ return (
214
+ item.type === ContentTypes.THINK ||
215
+ item.type === ContentTypes.THINKING ||
216
+ item.type === ContentTypes.REASONING ||
217
+ item.type === ContentTypes.REASONING_CONTENT ||
218
+ item.type === 'redacted_thinking'
219
+ );
220
+ });
221
+ }
222
+
146
223
  export function modifyDeltaProperties(
147
224
  provider: Providers,
148
225
  obj?: AIMessageChunk
@@ -287,25 +364,52 @@ export function convertMessagesToContent(
287
364
  ): t.MessageContentComplex[] {
288
365
  const processedContent: t.MessageContentComplex[] = [];
289
366
 
290
- const addContentPart = (message: BaseMessage | null): void => {
367
+ const addToolCallBoundary = (): number => {
368
+ processedContent.push({ type: ContentTypes.TEXT, text: '' });
369
+ return processedContent.length - 1;
370
+ };
371
+
372
+ const addContentPart = (message: BaseMessage | null): number | undefined => {
291
373
  const content =
292
374
  message?.lc_kwargs.content != null
293
375
  ? message.lc_kwargs.content
294
376
  : message?.content;
295
377
  if (content === undefined) {
296
- return;
378
+ return undefined;
379
+ }
380
+ const reasoningContent =
381
+ message?._getType() === 'ai' && !hasReasoningContent(content)
382
+ ? getAdditionalReasoningContent(message)
383
+ : undefined;
384
+ if (reasoningContent != null) {
385
+ processedContent.push({
386
+ type: ContentTypes.THINK,
387
+ think: reasoningContent,
388
+ });
297
389
  }
298
390
  if (typeof content === 'string') {
391
+ if (content === '') {
392
+ return undefined;
393
+ }
299
394
  processedContent.push({
300
- type: 'text',
395
+ type: ContentTypes.TEXT,
301
396
  text: content,
302
397
  });
398
+ return processedContent.length - 1;
303
399
  } else if (Array.isArray(content)) {
304
- const filteredContent = content.filter(
305
- (item) => item != null && item.type !== 'tool_use'
306
- );
307
- processedContent.push(...filteredContent);
400
+ let textContentIndex: number | undefined;
401
+ for (const item of content) {
402
+ if (item == null || item.type === 'tool_use') {
403
+ continue;
404
+ }
405
+ processedContent.push(item);
406
+ if (item.type === ContentTypes.TEXT) {
407
+ textContentIndex = processedContent.length - 1;
408
+ }
409
+ }
410
+ return textContentIndex;
308
411
  }
412
+ return undefined;
309
413
  };
310
414
 
311
415
  let currentAIMessageIndex = -1;
@@ -328,8 +432,8 @@ export function convertMessagesToContent(
328
432
  toolCallMap.set(tool_call.id, tool_call);
329
433
  }
330
434
 
331
- addContentPart(message);
332
- currentAIMessageIndex = processedContent.length - 1;
435
+ currentAIMessageIndex =
436
+ addContentPart(message) ?? addToolCallBoundary();
333
437
  continue;
334
438
  } else if (
335
439
  messageType === 'tool' &&
@@ -361,6 +465,12 @@ export function convertMessagesToContent(
361
465
  return processedContent;
362
466
  }
363
467
 
468
+ function stringifyToolMessageContent(
469
+ content: ToolMessage['content'] | null | undefined
470
+ ): string {
471
+ return content == null ? '' : String(content);
472
+ }
473
+
364
474
  export function formatAnthropicArtifactContent(messages: BaseMessage[]): void {
365
475
  const lastMessage = messages[messages.length - 1];
366
476
  if (!(lastMessage instanceof ToolMessage)) return;
@@ -398,7 +508,12 @@ export function formatAnthropicArtifactContent(messages: BaseMessage[]): void {
398
508
  ) {
399
509
  const base = Array.isArray(msg.content)
400
510
  ? msg.content
401
- : [{ type: 'text' as const, text: String(msg.content ?? '') }];
511
+ : [
512
+ {
513
+ type: ContentTypes.TEXT,
514
+ text: stringifyToolMessageContent(msg.content),
515
+ },
516
+ ];
402
517
  msg.content = base.concat(msg.artifact.content);
403
518
  }
404
519
  }
@@ -1,11 +1,16 @@
1
1
  import {
2
2
  HumanMessage,
3
3
  AIMessage,
4
+ AIMessageChunk,
4
5
  SystemMessage,
5
6
  ToolMessage,
6
7
  } from '@langchain/core/messages';
7
8
  import type { MessageContentComplex, TPayload } from '@/types';
8
9
  import { formatAgentMessages } from './format';
10
+ import {
11
+ convertMessagesToContent,
12
+ formatAnthropicArtifactContent,
13
+ } from './core';
9
14
  import { _convertMessagesToAnthropicPayload } from '@/llm/anthropic/utils/message_inputs';
10
15
  import { Constants, ContentTypes, Providers } from '@/common';
11
16
 
@@ -619,6 +624,123 @@ describe('formatAgentMessages', () => {
619
624
  ]);
620
625
  });
621
626
 
627
+ it('preserves tool-only assistant turn boundaries when converting messages to content', () => {
628
+ const messages = [
629
+ new AIMessage({
630
+ content: '',
631
+ tool_calls: [
632
+ {
633
+ id: 'call_1',
634
+ name: 'lookup',
635
+ args: { step: 1 },
636
+ type: 'tool_call' as const,
637
+ },
638
+ ],
639
+ }),
640
+ new ToolMessage({
641
+ content: 'first result',
642
+ tool_call_id: 'call_1',
643
+ name: 'lookup',
644
+ }),
645
+ new AIMessage({
646
+ content: '',
647
+ tool_calls: [
648
+ {
649
+ id: 'call_2',
650
+ name: 'lookup',
651
+ args: { step: 2 },
652
+ type: 'tool_call' as const,
653
+ },
654
+ ],
655
+ }),
656
+ new ToolMessage({
657
+ content: 'second result',
658
+ tool_call_id: 'call_2',
659
+ name: 'lookup',
660
+ }),
661
+ ];
662
+
663
+ const content = convertMessagesToContent(messages);
664
+ expect(content).toHaveLength(4);
665
+ expect(content[0]).toMatchObject({
666
+ type: ContentTypes.TEXT,
667
+ text: '',
668
+ tool_call_ids: ['call_1'],
669
+ });
670
+ expect(content[1]).toMatchObject({
671
+ type: ContentTypes.TOOL_CALL,
672
+ tool_call: {
673
+ id: 'call_1',
674
+ name: 'lookup',
675
+ output: 'first result',
676
+ },
677
+ });
678
+ expect(content[2]).toMatchObject({
679
+ type: ContentTypes.TEXT,
680
+ text: '',
681
+ tool_call_ids: ['call_2'],
682
+ });
683
+ expect(content[3]).toMatchObject({
684
+ type: ContentTypes.TOOL_CALL,
685
+ tool_call: {
686
+ id: 'call_2',
687
+ name: 'lookup',
688
+ output: 'second result',
689
+ },
690
+ });
691
+
692
+ const result = formatAgentMessages([{ role: 'assistant', content }]);
693
+ expect(result.messages).toHaveLength(4);
694
+ expect(result.messages[0]).toBeInstanceOf(AIMessage);
695
+ expect(result.messages[1]).toBeInstanceOf(ToolMessage);
696
+ expect(result.messages[2]).toBeInstanceOf(AIMessage);
697
+ expect(result.messages[3]).toBeInstanceOf(ToolMessage);
698
+ expect((result.messages[0] as AIMessage).tool_calls?.[0].id).toBe(
699
+ 'call_1'
700
+ );
701
+ expect((result.messages[1] as ToolMessage).tool_call_id).toBe('call_1');
702
+ expect((result.messages[2] as AIMessage).tool_calls?.[0].id).toBe(
703
+ 'call_2'
704
+ );
705
+ expect((result.messages[3] as ToolMessage).tool_call_id).toBe('call_2');
706
+ });
707
+
708
+ it('keeps absent tool content empty when merging Anthropic artifacts', () => {
709
+ const toolMessage = new ToolMessage({
710
+ content: '',
711
+ tool_call_id: 'call_artifact',
712
+ name: 'render',
713
+ artifact: {
714
+ content: [{ type: ContentTypes.TEXT, text: 'artifact text' }],
715
+ },
716
+ });
717
+ Object.defineProperty(toolMessage, 'content', {
718
+ value: undefined,
719
+ writable: true,
720
+ configurable: true,
721
+ });
722
+
723
+ formatAnthropicArtifactContent([
724
+ new AIMessageChunk({
725
+ content: '',
726
+ tool_calls: [
727
+ {
728
+ id: 'call_artifact',
729
+ name: 'render',
730
+ args: {},
731
+ type: 'tool_call' as const,
732
+ },
733
+ ],
734
+ }),
735
+ toolMessage,
736
+ ]);
737
+
738
+ expect(toolMessage.content).toEqual([
739
+ { type: ContentTypes.TEXT, text: '' },
740
+ { type: ContentTypes.TEXT, text: 'artifact text' },
741
+ ]);
742
+ });
743
+
622
744
  it('should dynamically discover tools from tool_search output and keep their tool calls', () => {
623
745
  const tools = new Set(['tool_search', 'calculator']);
624
746
  const payload = [
@@ -265,10 +265,15 @@ const skipTests = process.env.DEEPSEEK_API_KEY == null;
265
265
  const finalContentParts = await run.processStream(inputs, testConfig);
266
266
  expect(finalContentParts).toBeDefined();
267
267
 
268
- const allTextParts = finalContentParts?.every(
269
- (part) => part.type === ContentTypes.TEXT
268
+ const supportedContentParts = finalContentParts?.every(
269
+ (part) =>
270
+ part.type === ContentTypes.TEXT || part.type === ContentTypes.THINK
270
271
  );
271
- expect(allTextParts).toBe(true);
272
+ expect(supportedContentParts).toBe(true);
273
+ const textParts =
274
+ finalContentParts?.filter((part) => part.type === ContentTypes.TEXT) ??
275
+ [];
276
+ expect(textParts.length).toBeGreaterThan(0);
272
277
 
273
278
  expect(collectedUsage.length).toBeGreaterThan(0);
274
279
  expect(collectedUsage[0].input_tokens).toBeGreaterThan(0);
@@ -283,10 +283,15 @@ const skipTests = process.env.MOONSHOT_API_KEY == null;
283
283
  const finalContentParts = await run.processStream(inputs, testConfig);
284
284
  expect(finalContentParts).toBeDefined();
285
285
 
286
- const allTextParts = finalContentParts?.every(
287
- (part) => part.type === ContentTypes.TEXT
286
+ const supportedContentParts = finalContentParts?.every(
287
+ (part) =>
288
+ part.type === ContentTypes.TEXT || part.type === ContentTypes.THINK
288
289
  );
289
- expect(allTextParts).toBe(true);
290
+ expect(supportedContentParts).toBe(true);
291
+ const textParts =
292
+ finalContentParts?.filter((part) => part.type === ContentTypes.TEXT) ??
293
+ [];
294
+ expect(textParts.length).toBeGreaterThan(0);
290
295
 
291
296
  expect(collectedUsage.length).toBeGreaterThan(0);
292
297
  expect(collectedUsage[0].input_tokens).toBeGreaterThan(0);
@@ -11,6 +11,18 @@ jest.mock('@/utils', () => ({
11
11
  sleep: (): Promise<void> => Promise.resolve(),
12
12
  }));
13
13
 
14
+ const createRunStep = (id: string): t.RunStep => ({
15
+ id,
16
+ stepIndex: 0,
17
+ type: StepTypes.MESSAGE_CREATION,
18
+ index: 0,
19
+ stepDetails: {
20
+ type: StepTypes.MESSAGE_CREATION,
21
+ message_creation: { message_id: id },
22
+ },
23
+ usage: null,
24
+ });
25
+
14
26
  describe('Stream Generation and Handling', () => {
15
27
  let mockHandlers: {
16
28
  [GraphEvents.ON_RUN_STEP]: jest.Mock;
@@ -161,6 +173,58 @@ End code.`;
161
173
  });
162
174
  });
163
175
 
176
+ describe('ContentAggregator empty deltas', () => {
177
+ it('should ignore empty message delta content arrays', () => {
178
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
179
+ const { contentParts, aggregateContent } = createContentAggregator();
180
+
181
+ try {
182
+ aggregateContent({
183
+ event: GraphEvents.ON_RUN_STEP,
184
+ data: createRunStep('step_empty_message'),
185
+ });
186
+
187
+ aggregateContent({
188
+ event: GraphEvents.ON_MESSAGE_DELTA,
189
+ data: {
190
+ id: 'step_empty_message',
191
+ delta: { content: [] },
192
+ },
193
+ });
194
+
195
+ expect(warnSpy).not.toHaveBeenCalled();
196
+ expect(contentParts).toEqual([]);
197
+ } finally {
198
+ warnSpy.mockRestore();
199
+ }
200
+ });
201
+
202
+ it('should ignore empty reasoning delta content arrays', () => {
203
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
204
+ const { contentParts, aggregateContent } = createContentAggregator();
205
+
206
+ try {
207
+ aggregateContent({
208
+ event: GraphEvents.ON_RUN_STEP,
209
+ data: createRunStep('step_empty_reasoning'),
210
+ });
211
+
212
+ aggregateContent({
213
+ event: GraphEvents.ON_REASONING_DELTA,
214
+ data: {
215
+ id: 'step_empty_reasoning',
216
+ delta: { content: [] },
217
+ },
218
+ });
219
+
220
+ expect(warnSpy).not.toHaveBeenCalled();
221
+ expect(contentParts).toEqual([]);
222
+ } finally {
223
+ warnSpy.mockRestore();
224
+ }
225
+ });
226
+ });
227
+
164
228
  describe('ContentAggregator with SplitStreamHandler', () => {
165
229
  it('should aggregate content from multiple message blocks', async () => {
166
230
  const runId = nanoid();
package/src/stream.ts CHANGED
@@ -1317,6 +1317,14 @@ export function createContentAggregator(): t.ContentAggregatorResult {
1317
1317
  number,
1318
1318
  { agentId?: string; groupId?: number }
1319
1319
  >();
1320
+ const getFirstContentPart = (
1321
+ content?: t.MessageDelta['content'] | t.MessageContentComplex
1322
+ ): t.MessageContentComplex | undefined => {
1323
+ if (content == null) {
1324
+ return undefined;
1325
+ }
1326
+ return Array.isArray(content) ? content[0] : content;
1327
+ };
1320
1328
 
1321
1329
  const updateContent = (
1322
1330
  index: number,
@@ -1588,11 +1596,8 @@ export function createContentAggregator(): t.ContentAggregatorResult {
1588
1596
  return;
1589
1597
  }
1590
1598
 
1591
- if (messageDelta.delta.content) {
1592
- const contentPart = Array.isArray(messageDelta.delta.content)
1593
- ? messageDelta.delta.content[0]
1594
- : messageDelta.delta.content;
1595
-
1599
+ const contentPart = getFirstContentPart(messageDelta.delta.content);
1600
+ if (contentPart != null) {
1596
1601
  updateContent(runStep.index, contentPart);
1597
1602
  }
1598
1603
  } else if (
@@ -1612,11 +1617,8 @@ export function createContentAggregator(): t.ContentAggregatorResult {
1612
1617
  return;
1613
1618
  }
1614
1619
 
1615
- if (reasoningDelta.delta.content) {
1616
- const contentPart = Array.isArray(reasoningDelta.delta.content)
1617
- ? reasoningDelta.delta.content[0]
1618
- : reasoningDelta.delta.content;
1619
-
1620
+ const contentPart = getFirstContentPart(reasoningDelta.delta.content);
1621
+ if (contentPart != null) {
1620
1622
  updateContent(runStep.index, contentPart);
1621
1623
  }
1622
1624
  } else if (event === GraphEvents.ON_RUN_STEP_DELTA) {