@librechat/agents 3.0.54 → 3.0.55

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.
@@ -203,6 +203,14 @@ export class MultiAgentGraph extends StandardGraph {
203
203
  return this.agentParallelGroups.get(agentId);
204
204
  }
205
205
 
206
+ /**
207
+ * Override to indicate this is a multi-agent graph.
208
+ * Enables agentId to be included in RunStep for frontend agent labeling.
209
+ */
210
+ protected override isMultiAgentGraph(): boolean {
211
+ return true;
212
+ }
213
+
206
214
  /**
207
215
  * Override base class method to provide parallel group IDs for run steps.
208
216
  */
@@ -14,6 +14,7 @@ import type {
14
14
  ExtendedMessageContent,
15
15
  MessageContentComplex,
16
16
  ReasoningContentText,
17
+ ContentMetadata,
17
18
  ToolCallContent,
18
19
  ToolCallPart,
19
20
  TPayload,
@@ -626,23 +627,31 @@ export const labelContentByAgent = (
626
627
  * @param payload - The array of messages to format.
627
628
  * @param indexTokenCountMap - Optional map of message indices to token counts.
628
629
  * @param tools - Optional set of tool names that are allowed in the request.
630
+ * @param options - Optional configuration for agent filtering.
631
+ * @param options.targetAgentId - If provided, only content parts from this agent will be included.
632
+ * @param options.contentMetadataMap - Map of content index to metadata (required when targetAgentId is provided).
629
633
  * @returns - Object containing formatted messages and updated indexTokenCountMap if provided.
630
634
  */
631
635
  export const formatAgentMessages = (
632
636
  payload: TPayload,
633
- indexTokenCountMap?: Record<number, number>,
634
- tools?: Set<string>
637
+ indexTokenCountMap?: Record<number, number | undefined>,
638
+ tools?: Set<string>,
639
+ options?: {
640
+ targetAgentId?: string;
641
+ contentMetadataMap?: Map<number, ContentMetadata>;
642
+ }
635
643
  ): {
636
644
  messages: Array<HumanMessage | AIMessage | SystemMessage | ToolMessage>;
637
645
  indexTokenCountMap?: Record<number, number>;
638
646
  } => {
647
+ const { targetAgentId, contentMetadataMap } = options ?? {};
639
648
  const messages: Array<
640
649
  HumanMessage | AIMessage | SystemMessage | ToolMessage
641
650
  > = [];
642
651
  // If indexTokenCountMap is provided, create a new map to track the updated indices
643
652
  const updatedIndexTokenCountMap: Record<number, number> = {};
644
653
  // Keep track of the mapping from original payload indices to result indices
645
- const indexMapping: Record<number, number[]> = {};
654
+ const indexMapping: Record<number, number[] | undefined> = {};
646
655
 
647
656
  // Process messages with tool conversion if tools set is provided
648
657
  for (let i = 0; i < payload.length; i++) {
@@ -654,6 +663,27 @@ export const formatAgentMessages = (
654
663
  { type: ContentTypes.TEXT, [ContentTypes.TEXT]: message.content },
655
664
  ];
656
665
  }
666
+
667
+ // Filter content parts by targetAgentId if provided (only for assistant messages with array content)
668
+ if (
669
+ targetAgentId != null &&
670
+ targetAgentId !== '' &&
671
+ contentMetadataMap != null &&
672
+ message.role === 'assistant' &&
673
+ Array.isArray(message.content)
674
+ ) {
675
+ const filteredContent = message.content.filter((_, partIndex) => {
676
+ const metadata = contentMetadataMap.get(partIndex);
677
+ return metadata?.agentId === targetAgentId;
678
+ });
679
+ // Skip this message entirely if no content parts match the target agent
680
+ if (filteredContent.length === 0) {
681
+ indexMapping[i] = [];
682
+ continue;
683
+ }
684
+ message.content = filteredContent;
685
+ }
686
+
657
687
  if (message.role !== 'assistant') {
658
688
  messages.push(
659
689
  formatMessage({
@@ -1141,4 +1141,172 @@ describe('formatAgentMessages', () => {
1141
1141
  expect(result.messages[1].name).toBe('search');
1142
1142
  expect(result.messages[1].content).toBe('');
1143
1143
  });
1144
+
1145
+ describe('targetAgentId filtering', () => {
1146
+ it('should filter content parts to only include those from targetAgentId', () => {
1147
+ const payload: TPayload = [
1148
+ { role: 'user', content: 'Hello' },
1149
+ {
1150
+ role: 'assistant',
1151
+ content: [
1152
+ { type: ContentTypes.TEXT, text: 'Response from agent_a' },
1153
+ { type: ContentTypes.TEXT, text: 'Response from agent_b' },
1154
+ { type: ContentTypes.TEXT, text: 'Another from agent_a' },
1155
+ ],
1156
+ },
1157
+ ];
1158
+
1159
+ const contentMetadataMap = new Map([
1160
+ [0, { agentId: 'agent_a' }],
1161
+ [1, { agentId: 'agent_b' }],
1162
+ [2, { agentId: 'agent_a' }],
1163
+ ]);
1164
+
1165
+ const result = formatAgentMessages(payload, undefined, undefined, {
1166
+ targetAgentId: 'agent_a',
1167
+ contentMetadataMap,
1168
+ });
1169
+
1170
+ // Should have user message + filtered assistant message
1171
+ expect(result.messages).toHaveLength(2);
1172
+ expect(result.messages[0]).toBeInstanceOf(HumanMessage);
1173
+ expect(result.messages[1]).toBeInstanceOf(AIMessage);
1174
+
1175
+ // The AIMessage should only have agent_a's content parts
1176
+ const aiMessage = result.messages[1] as AIMessage;
1177
+ expect(Array.isArray(aiMessage.content)).toBe(true);
1178
+ expect(aiMessage.content as Array<{ text: string }>).toHaveLength(2);
1179
+ expect((aiMessage.content as Array<{ text: string }>)[0].text).toBe(
1180
+ 'Response from agent_a'
1181
+ );
1182
+ expect((aiMessage.content as Array<{ text: string }>)[1].text).toBe(
1183
+ 'Another from agent_a'
1184
+ );
1185
+ });
1186
+
1187
+ it('should skip assistant message entirely if no content parts match targetAgentId', () => {
1188
+ const payload: TPayload = [
1189
+ { role: 'user', content: 'Hello' },
1190
+ {
1191
+ role: 'assistant',
1192
+ content: [{ type: ContentTypes.TEXT, text: 'Response from agent_b' }],
1193
+ },
1194
+ ];
1195
+
1196
+ const contentMetadataMap = new Map([[0, { agentId: 'agent_b' }]]);
1197
+
1198
+ const result = formatAgentMessages(payload, undefined, undefined, {
1199
+ targetAgentId: 'agent_a',
1200
+ contentMetadataMap,
1201
+ });
1202
+
1203
+ // Should only have the user message, assistant message skipped
1204
+ expect(result.messages).toHaveLength(1);
1205
+ expect(result.messages[0]).toBeInstanceOf(HumanMessage);
1206
+ });
1207
+
1208
+ it('should not filter when targetAgentId is not provided', () => {
1209
+ const payload: TPayload = [
1210
+ {
1211
+ role: 'assistant',
1212
+ content: [
1213
+ { type: ContentTypes.TEXT, text: 'Response from agent_a' },
1214
+ { type: ContentTypes.TEXT, text: 'Response from agent_b' },
1215
+ ],
1216
+ },
1217
+ ];
1218
+
1219
+ const contentMetadataMap = new Map([
1220
+ [0, { agentId: 'agent_a' }],
1221
+ [1, { agentId: 'agent_b' }],
1222
+ ]);
1223
+
1224
+ // No targetAgentId provided - should include all content
1225
+ const result = formatAgentMessages(payload, undefined, undefined, {
1226
+ contentMetadataMap,
1227
+ });
1228
+
1229
+ expect(result.messages).toHaveLength(1);
1230
+ const aiMessage = result.messages[0] as AIMessage;
1231
+ expect(Array.isArray(aiMessage.content)).toBe(true);
1232
+ expect(aiMessage.content as Array<{ text: string }>).toHaveLength(2);
1233
+ });
1234
+
1235
+ it('should not filter when contentMetadataMap is not provided', () => {
1236
+ const payload: TPayload = [
1237
+ {
1238
+ role: 'assistant',
1239
+ content: [
1240
+ { type: ContentTypes.TEXT, text: 'Response 1' },
1241
+ { type: ContentTypes.TEXT, text: 'Response 2' },
1242
+ ],
1243
+ },
1244
+ ];
1245
+
1246
+ // targetAgentId provided but no contentMetadataMap - should include all content
1247
+ const result = formatAgentMessages(payload, undefined, undefined, {
1248
+ targetAgentId: 'agent_a',
1249
+ });
1250
+
1251
+ expect(result.messages).toHaveLength(1);
1252
+ const aiMessage = result.messages[0] as AIMessage;
1253
+ expect(Array.isArray(aiMessage.content)).toBe(true);
1254
+ expect(aiMessage.content as Array<{ text: string }>).toHaveLength(2);
1255
+ });
1256
+
1257
+ it('should filter content with groupId metadata (parallel execution)', () => {
1258
+ const payload: TPayload = [
1259
+ { role: 'user', content: 'Analyze this' },
1260
+ {
1261
+ role: 'assistant',
1262
+ content: [
1263
+ { type: ContentTypes.TEXT, text: 'Creative analysis' },
1264
+ { type: ContentTypes.TEXT, text: 'Practical analysis' },
1265
+ ],
1266
+ },
1267
+ ];
1268
+
1269
+ const contentMetadataMap = new Map([
1270
+ [0, { agentId: 'creative_analyst', groupId: 1 }],
1271
+ [1, { agentId: 'practical_analyst', groupId: 1 }],
1272
+ ]);
1273
+
1274
+ const result = formatAgentMessages(payload, undefined, undefined, {
1275
+ targetAgentId: 'creative_analyst',
1276
+ contentMetadataMap,
1277
+ });
1278
+
1279
+ expect(result.messages).toHaveLength(2);
1280
+ const aiMessage = result.messages[1] as AIMessage;
1281
+ expect(Array.isArray(aiMessage.content)).toBe(true);
1282
+ expect(aiMessage.content as Array<{ text: string }>).toHaveLength(1);
1283
+ expect((aiMessage.content as Array<{ text: string }>)[0].text).toBe(
1284
+ 'Creative analysis'
1285
+ );
1286
+ });
1287
+
1288
+ it('should not affect non-assistant messages when filtering', () => {
1289
+ const payload: TPayload = [
1290
+ { role: 'user', content: 'Hello from user' },
1291
+ { role: 'system', content: 'System message' },
1292
+ {
1293
+ role: 'assistant',
1294
+ content: [{ type: ContentTypes.TEXT, text: 'From agent_a' }],
1295
+ },
1296
+ ];
1297
+
1298
+ const contentMetadataMap = new Map([[0, { agentId: 'agent_a' }]]);
1299
+
1300
+ const result = formatAgentMessages(payload, undefined, undefined, {
1301
+ targetAgentId: 'agent_a',
1302
+ contentMetadataMap,
1303
+ });
1304
+
1305
+ // All three messages should be present
1306
+ expect(result.messages).toHaveLength(3);
1307
+ expect(result.messages[0]).toBeInstanceOf(HumanMessage);
1308
+ expect(result.messages[1]).toBeInstanceOf(SystemMessage);
1309
+ expect(result.messages[2]).toBeInstanceOf(AIMessage);
1310
+ });
1311
+ });
1144
1312
  });
@@ -25,7 +25,8 @@ async function testParallelFromStart() {
25
25
  console.log('Testing Parallel From Start Multi-Agent System...\n');
26
26
 
27
27
  // Set up content aggregator
28
- const { contentParts, aggregateContent } = createContentAggregator();
28
+ const { contentParts, aggregateContent, contentMetadataMap } =
29
+ createContentAggregator();
29
30
 
30
31
  // Define two agents - both have NO incoming edges, so they run in parallel from the start
31
32
  const agents: t.AgentInputs[] = [
@@ -249,9 +250,10 @@ async function testParallelFromStart() {
249
250
  console.log('====================================\n');
250
251
 
251
252
  console.log('Final content parts:', contentParts.length, 'parts');
253
+ console.log('\n=== Content Parts (clean, no metadata) ===');
252
254
  console.dir(contentParts, { depth: null });
253
-
254
- // groupId on each content part allows frontend to derive boundaries if needed
255
+ console.log('\n=== Content Metadata Map (separate from content) ===');
256
+ console.dir(Object.fromEntries(contentMetadataMap), { depth: null });
255
257
 
256
258
  await sleep(3000);
257
259
  } catch (error) {
@@ -22,7 +22,8 @@ async function testSequentialMultiAgent() {
22
22
  console.log('Testing Sequential Multi-Agent System (A → B → C)...\n');
23
23
 
24
24
  // Set up content aggregator
25
- const { contentParts, aggregateContent } = createContentAggregator();
25
+ const { contentParts, aggregateContent, contentMetadataMap } =
26
+ createContentAggregator();
26
27
 
27
28
  // Define three simple agents
28
29
  const agents: t.AgentInputs[] = [
@@ -194,6 +195,10 @@ async function testSequentialMultiAgent() {
194
195
  console.log('\n\n=== Final Output ===');
195
196
  console.log('Sequential flow completed successfully!');
196
197
  console.log(`Total content parts: ${contentParts.length}`);
198
+ console.log('\n=== Content Parts (clean, no metadata) ===');
199
+ console.dir(contentParts, { depth: null });
200
+ console.log('\n=== Content Metadata Map (separate from content) ===');
201
+ console.dir(Object.fromEntries(contentMetadataMap), { depth: null });
197
202
 
198
203
  // Display the sequential responses
199
204
  const aiMessages = conversationHistory.filter(
@@ -20,7 +20,8 @@ async function testSingleAgent() {
20
20
  console.log('Testing Single Agent with Metadata Logging...\n');
21
21
 
22
22
  // Set up content aggregator
23
- const { contentParts, aggregateContent } = createContentAggregator();
23
+ const { contentParts, aggregateContent, contentMetadataMap } =
24
+ createContentAggregator();
24
25
 
25
26
  const startTime = Date.now();
26
27
 
@@ -179,7 +180,12 @@ async function testSingleAgent() {
179
180
 
180
181
  console.log('\n\n========== SUMMARY ==========');
181
182
  console.log('Final content parts:', contentParts.length, 'parts');
183
+ console.log('\n=== Content Parts (clean, no metadata) ===');
182
184
  console.dir(contentParts, { depth: null });
185
+ console.log(
186
+ '\n=== Content Metadata Map (should be empty for single-agent) ==='
187
+ );
188
+ console.dir(Object.fromEntries(contentMetadataMap), { depth: null });
183
189
  console.log('====================================\n');
184
190
 
185
191
  await sleep(3000);
package/src/stream.ts CHANGED
@@ -583,15 +583,6 @@ export function createContentAggregator(): t.ContentAggregatorResult {
583
583
  tool_call: newToolCall,
584
584
  };
585
585
  }
586
-
587
- // Apply agentId and groupId to content part for parallel execution attribution
588
- const meta = contentMetaMap.get(index);
589
- if (meta?.agentId !== undefined) {
590
- (contentParts[index] as t.MessageContentComplex).agentId = meta.agentId;
591
- }
592
- if (meta?.groupId !== undefined) {
593
- (contentParts[index] as t.MessageContentComplex).groupId = meta.groupId;
594
- }
595
586
  };
596
587
 
597
588
  const aggregateContent = ({
@@ -610,18 +601,19 @@ export function createContentAggregator(): t.ContentAggregatorResult {
610
601
  const runStep = data as t.RunStep;
611
602
  stepMap.set(runStep.id, runStep);
612
603
 
613
- // Track agentId and groupId for this content index
614
- const existingMeta = contentMetaMap.get(runStep.index) ?? {};
615
- if (runStep.agentId != null && runStep.agentId !== '') {
616
- existingMeta.agentId = runStep.agentId;
617
- }
618
- if (runStep.groupId != null) {
619
- existingMeta.groupId = runStep.groupId;
620
- }
621
- if (
622
- (existingMeta.agentId != null && existingMeta.agentId !== '') ||
623
- existingMeta.groupId != null
624
- ) {
604
+ // Track agentId (MultiAgentGraph) and groupId (parallel execution) separately
605
+ // - agentId: present for all MultiAgentGraph runs (enables agent labels in UI)
606
+ // - groupId: present only for parallel execution (enables column rendering)
607
+ const hasAgentId = runStep.agentId != null && runStep.agentId !== '';
608
+ const hasGroupId = runStep.groupId != null;
609
+ if (hasAgentId || hasGroupId) {
610
+ const existingMeta = contentMetaMap.get(runStep.index) ?? {};
611
+ if (hasAgentId) {
612
+ existingMeta.agentId = runStep.agentId;
613
+ }
614
+ if (hasGroupId) {
615
+ existingMeta.groupId = runStep.groupId;
616
+ }
625
617
  contentMetaMap.set(runStep.index, existingMeta);
626
618
  }
627
619
 
@@ -735,5 +727,10 @@ export function createContentAggregator(): t.ContentAggregatorResult {
735
727
  }
736
728
  };
737
729
 
738
- return { contentParts, aggregateContent, stepMap };
730
+ return {
731
+ contentParts,
732
+ aggregateContent,
733
+ stepMap,
734
+ contentMetadataMap: contentMetaMap,
735
+ };
739
736
  }
@@ -343,10 +343,6 @@ export type MessageContentComplex = (
343
343
  })
344
344
  ) & {
345
345
  tool_call_ids?: string[];
346
- // Optional agentId for parallel execution attribution
347
- agentId?: string;
348
- // Optional groupId for parallel group attribution
349
- groupId?: number;
350
346
  };
351
347
 
352
348
  export interface TMessage {
@@ -410,8 +406,20 @@ export type ContentAggregator = ({
410
406
  result: ToolEndEvent;
411
407
  };
412
408
  }) => void;
409
+ /**
410
+ * Metadata for content parts in multi-agent runs.
411
+ * - agentId: present for all MultiAgentGraph runs (enables agent labels in UI)
412
+ * - groupId: present only for parallel execution (enables column rendering)
413
+ */
414
+ export type ContentMetadata = {
415
+ agentId?: string;
416
+ groupId?: number;
417
+ };
418
+
413
419
  export type ContentAggregatorResult = {
414
420
  stepMap: Map<string, RunStep | undefined>;
415
421
  contentParts: Array<MessageContentComplex | undefined>;
422
+ /** Map of content index to metadata (agentId, groupId). Only populated for MultiAgentGraph runs. */
423
+ contentMetadataMap: Map<number, ContentMetadata>;
416
424
  aggregateContent: ContentAggregator;
417
425
  };