@librechat/agents 2.2.4 → 2.2.6

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,8 +1,8 @@
1
1
  import { ToolMessage, BaseMessage } from '@langchain/core/messages';
2
- import { HumanMessage, AIMessage, SystemMessage } from '@langchain/core/messages';
3
- import { MessageContentImageUrl } from '@langchain/core/messages';
2
+ import { HumanMessage, AIMessage, SystemMessage, getBufferString } from '@langchain/core/messages';
3
+ import type { MessageContentImageUrl } from '@langchain/core/messages';
4
4
  import type { ToolCall } from '@langchain/core/messages/tool';
5
- import type { MessageContentComplex } from '@/types';
5
+ import type { MessageContentComplex, ToolCallPart, TPayload, TMessage } from '@/types';
6
6
  import { Providers, ContentTypes } from '@/common';
7
7
 
8
8
  interface VisionMessageParams {
@@ -201,39 +201,118 @@ export const formatFromLangChain = (message: LangChainMessage): Record<string, a
201
201
  };
202
202
  };
203
203
 
204
- interface TMessage {
205
- role?: string;
206
- content?: string | Array<{
207
- type: ContentTypes;
208
- [ContentTypes.TEXT]?: string;
209
- text?: string;
210
- tool_call_ids?: string[];
211
- [key: string]: any;
212
- }>;
213
- [key: string]: any;
214
- }
204
+ /**
205
+ * Helper function to format an assistant message
206
+ * @param message The message to format
207
+ * @returns Array of formatted messages
208
+ */
209
+ function formatAssistantMessage(message: Partial<TMessage>): Array<AIMessage | ToolMessage> {
210
+ const formattedMessages: Array<AIMessage | ToolMessage> = [];
211
+ let currentContent: MessageContentComplex[] = [];
212
+ let lastAIMessage: AIMessage | null = null;
213
+ let hasReasoning = false;
214
+
215
+ if (Array.isArray(message.content)) {
216
+ for (const part of message.content) {
217
+ if (part.type === ContentTypes.TEXT && part.tool_call_ids) {
218
+ /*
219
+ If there's pending content, it needs to be aggregated as a single string to prepare for tool calls.
220
+ For Anthropic models, the "tool_calls" field on a message is only respected if content is a string.
221
+ */
222
+ if (currentContent.length > 0) {
223
+ let content = currentContent.reduce((acc, curr) => {
224
+ if (curr.type === ContentTypes.TEXT) {
225
+ return `${acc}${curr[ContentTypes.TEXT] || ''}\n`;
226
+ }
227
+ return acc;
228
+ }, '');
229
+ content = `${content}\n${part[ContentTypes.TEXT] ?? part.text ?? ''}`.trim();
230
+ lastAIMessage = new AIMessage({ content });
231
+ formattedMessages.push(lastAIMessage);
232
+ currentContent = [];
233
+ continue;
234
+ }
235
+ // Create a new AIMessage with this text and prepare for tool calls
236
+ lastAIMessage = new AIMessage({
237
+ content: part.text || '',
238
+ });
239
+ formattedMessages.push(lastAIMessage);
240
+ } else if (part?.type === ContentTypes.TOOL_CALL) {
241
+ if (!lastAIMessage) {
242
+ throw new Error('Invalid tool call structure: No preceding AIMessage with tool_call_ids');
243
+ }
215
244
 
216
- interface ToolCallPart {
217
- type: ContentTypes.TOOL_CALL;
218
- tool_call: {
219
- id: string;
220
- name: string;
221
- args: string | Record<string, unknown>;
222
- output?: string;
223
- [key: string]: any;
224
- };
245
+ // Note: `tool_calls` list is defined when constructed by `AIMessage` class, and outputs should be excluded from it
246
+ const { output, args: _args, ..._tool_call } = (part.tool_call as ToolCallPart);
247
+ const tool_call: ToolCallPart = _tool_call;
248
+ // TODO: investigate; args as dictionary may need to be providers-or-tool-specific
249
+ let args: any = _args;
250
+ try {
251
+ if (typeof _args === 'string') {
252
+ args = JSON.parse(_args);
253
+ }
254
+ } catch (e) {
255
+ if (typeof _args === 'string') {
256
+ args = { input: _args };
257
+ }
258
+ }
259
+
260
+ tool_call.args = args;
261
+ if (!lastAIMessage.tool_calls) {
262
+ lastAIMessage.tool_calls = [];
263
+ }
264
+ lastAIMessage.tool_calls.push(tool_call as ToolCall);
265
+
266
+ formattedMessages.push(
267
+ new ToolMessage({
268
+ tool_call_id: tool_call.id ?? '',
269
+ name: tool_call.name,
270
+ content: output || '',
271
+ }),
272
+ );
273
+ } else if (part.type === ContentTypes.THINK) {
274
+ hasReasoning = true;
275
+ continue;
276
+ } else if (part.type === ContentTypes.ERROR || part.type === ContentTypes.AGENT_UPDATE) {
277
+ continue;
278
+ } else {
279
+ currentContent.push(part);
280
+ }
281
+ }
282
+ }
283
+
284
+ if (hasReasoning && currentContent.length > 0) {
285
+ const content = currentContent
286
+ .reduce((acc, curr) => {
287
+ if (curr.type === ContentTypes.TEXT) {
288
+ return `${acc}${curr[ContentTypes.TEXT] || ''}\n`;
289
+ }
290
+ return acc;
291
+ }, '')
292
+ .trim();
293
+
294
+ if (content) {
295
+ formattedMessages.push(new AIMessage({ content }));
296
+ }
297
+ } else if (currentContent.length > 0) {
298
+ formattedMessages.push(new AIMessage({ content: currentContent }));
299
+ }
300
+
301
+ return formattedMessages;
225
302
  }
226
303
 
227
304
  /**
228
305
  * Formats an array of messages for LangChain, handling tool calls and creating ToolMessage instances.
229
306
  *
230
- * @param {Array<Partial<TMessage>>} payload - The array of messages to format.
307
+ * @param {TPayload} payload - The array of messages to format.
231
308
  * @param {Record<number, number>} [indexTokenCountMap] - Optional map of message indices to token counts.
309
+ * @param {Set<string>} [tools] - Optional set of tool names that are allowed in the request.
232
310
  * @returns {Object} - Object containing formatted messages and updated indexTokenCountMap if provided.
233
311
  */
234
312
  export const formatAgentMessages = (
235
- payload: Array<Partial<TMessage>>,
236
- indexTokenCountMap?: Record<number, number>
313
+ payload: TPayload,
314
+ indexTokenCountMap?: Record<number, number>,
315
+ tools?: Set<string>
237
316
  ): {
238
317
  messages: Array<HumanMessage | AIMessage | SystemMessage | ToolMessage>;
239
318
  indexTokenCountMap?: Record<number, number>;
@@ -244,6 +323,7 @@ export const formatAgentMessages = (
244
323
  // Keep track of the mapping from original payload indices to result indices
245
324
  const indexMapping: Record<number, number[]> = {};
246
325
 
326
+ // Process messages with tool conversion if tools set is provided
247
327
  for (let i = 0; i < payload.length; i++) {
248
328
  const message = payload[i];
249
329
  // Q: Store the current length of messages to track where this payload message starts in the result?
@@ -265,97 +345,88 @@ export const formatAgentMessages = (
265
345
  // For assistant messages, track the starting index before processing
266
346
  const startMessageIndex = messages.length;
267
347
 
268
- let currentContent: any[] = [];
269
- let lastAIMessage: AIMessage | null = null;
270
-
271
- let hasReasoning = false;
272
- if (Array.isArray(message.content)) {
273
- for (const part of message.content) {
274
- if (part.type === ContentTypes.TEXT && part.tool_call_ids) {
275
- /*
276
- If there's pending content, it needs to be aggregated as a single string to prepare for tool calls.
277
- For Anthropic models, the "tool_calls" field on a message is only respected if content is a string.
278
- */
279
- if (currentContent.length > 0) {
280
- let content = currentContent.reduce((acc, curr) => {
281
- if (curr.type === ContentTypes.TEXT) {
282
- return `${acc}${curr[ContentTypes.TEXT] || ''}\n`;
283
- }
284
- return acc;
285
- }, '');
286
- content = `${content}\n${part[ContentTypes.TEXT] ?? part.text ?? ''}`.trim();
287
- lastAIMessage = new AIMessage({ content });
288
- messages.push(lastAIMessage);
289
- currentContent = [];
290
- continue;
291
- }
292
-
293
- // Create a new AIMessage with this text and prepare for tool calls
294
- lastAIMessage = new AIMessage({
295
- content: part.text || '',
296
- });
297
-
298
- messages.push(lastAIMessage);
299
- } else if (part.type === ContentTypes.TOOL_CALL) {
300
- if (!lastAIMessage) {
301
- throw new Error('Invalid tool call structure: No preceding AIMessage with tool_call_ids');
302
- }
303
-
304
- // Note: `tool_calls` list is defined when constructed by `AIMessage` class, and outputs should be excluded from it
305
- const { output, args: _args, ...tool_call } = (part.tool_call as any);
306
- // TODO: investigate; args as dictionary may need to be providers-or-tool-specific
307
- let args: any = _args;
308
- try {
309
- if (typeof _args === 'string') {
310
- args = JSON.parse(_args);
348
+ // If tools set is provided, we need to check if we need to convert tool messages to a string
349
+ if (tools) {
350
+ // First, check if this message contains tool calls
351
+ let hasToolCalls = false;
352
+ let hasInvalidTool = false;
353
+ let toolNames: string[] = [];
354
+
355
+ const content = message.content;
356
+ if (content && Array.isArray(content)) {
357
+ for (const part of content) {
358
+ if (part?.type === ContentTypes.TOOL_CALL) {
359
+ hasToolCalls = true;
360
+ if (tools.size === 0) {
361
+ hasInvalidTool = true;
362
+ break;
311
363
  }
312
- } catch (e) {
313
- if (typeof _args === 'string') {
314
- args = { input: _args };
364
+ const toolName = part.tool_call.name;
365
+ toolNames.push(toolName);
366
+ if (!tools.has(toolName)) {
367
+ hasInvalidTool = true;
315
368
  }
316
369
  }
317
-
318
- tool_call.args = args;
319
- if (!lastAIMessage.tool_calls) {
320
- lastAIMessage.tool_calls = [];
321
- }
322
- lastAIMessage.tool_calls.push(tool_call as ToolCall);
323
-
324
- // Add the corresponding ToolMessage
325
- messages.push(
326
- new ToolMessage({
327
- tool_call_id: tool_call.id,
328
- name: tool_call.name,
329
- content: output || '',
330
- }),
331
- );
332
- } else if (part.type === ContentTypes.THINK) {
333
- hasReasoning = true;
334
- continue;
335
- } else if (part.type === ContentTypes.ERROR || part.type === ContentTypes.AGENT_UPDATE) {
336
- continue;
337
- } else {
338
- currentContent.push(part);
339
370
  }
340
371
  }
341
- }
342
-
343
- if (hasReasoning && currentContent.length > 0) {
344
- const content = currentContent
345
- .reduce((acc, curr) => {
346
- if (curr.type === ContentTypes.TEXT) {
347
- return `${acc}${curr[ContentTypes.TEXT] || ''}\n`;
348
- }
349
- return acc;
350
- }, '')
351
- .trim();
352
372
 
353
- if (content) {
354
- messages.push(new AIMessage({ content }));
373
+ // If this message has tool calls and at least one is invalid, we need to convert it
374
+ if (hasToolCalls && hasInvalidTool) {
375
+ // We need to collect all related messages (this message and any subsequent tool messages)
376
+ const toolSequence: BaseMessage[] = [];
377
+ let sequenceEndIndex = i;
378
+
379
+ // Process the current assistant message to get the AIMessage with tool calls
380
+ const formattedMessages = formatAssistantMessage(message);
381
+ toolSequence.push(...formattedMessages);
382
+
383
+ // Look ahead for any subsequent assistant messages that might be part of this tool sequence
384
+ let j = i + 1;
385
+ while (j < payload.length && payload[j].role === 'assistant') {
386
+ // Check if this is a continuation of the tool sequence
387
+ let isToolResponse = false;
388
+ const content = payload[j].content;
389
+ if (content && Array.isArray(content)) {
390
+ for (const part of content) {
391
+ if (part?.type === ContentTypes.TOOL_CALL) {
392
+ isToolResponse = true;
393
+ break;
394
+ }
395
+ }
396
+ }
397
+
398
+ if (isToolResponse) {
399
+ // This is part of the tool sequence, add it
400
+ const nextMessages = formatAssistantMessage(payload[j]);
401
+ toolSequence.push(...nextMessages);
402
+ sequenceEndIndex = j;
403
+ j++;
404
+ } else {
405
+ // This is not part of the tool sequence, stop looking
406
+ break;
407
+ }
408
+ }
409
+
410
+ // Convert the sequence to a string
411
+ const bufferString = getBufferString(toolSequence);
412
+ messages.push(new AIMessage({ content: bufferString }));
413
+
414
+ // Skip the messages we've already processed
415
+ i = sequenceEndIndex;
416
+
417
+ // Update the index mapping for this sequence
418
+ const resultIndices = [messages.length - 1];
419
+ for (let k = i; k >= i && k <= sequenceEndIndex; k++) {
420
+ indexMapping[k] = resultIndices;
421
+ }
422
+
423
+ continue;
355
424
  }
356
- } else if (currentContent.length > 0) {
357
- messages.push(new AIMessage({ content: currentContent }));
358
425
  }
426
+
427
+ // Process the assistant message using the helper function
428
+ const formattedMessages = formatAssistantMessage(message);
429
+ messages.push(...formattedMessages);
359
430
 
360
431
  // Update the index mapping for this assistant message
361
432
  // Store all indices that were created from this original message
@@ -446,8 +517,6 @@ export function shiftIndexTokenCountMap(
446
517
  ): Record<number, number> {
447
518
  // Create a new map to avoid modifying the original
448
519
  const shiftedMap: Record<number, number> = {};
449
-
450
- // Add the system message token count at index 0
451
520
  shiftedMap[0] = instructionsTokenCount;
452
521
 
453
522
  // Shift all existing indices by 1
@@ -1,10 +1,11 @@
1
1
  import { HumanMessage, AIMessage, SystemMessage, ToolMessage } from '@langchain/core/messages';
2
+ import type { TPayload } from '@/types';
2
3
  import { formatAgentMessages } from './format';
3
4
  import { ContentTypes } from '@/common';
4
5
 
5
6
  describe('formatAgentMessages', () => {
6
7
  it('should format simple user and AI messages', () => {
7
- const payload = [
8
+ const payload: TPayload = [
8
9
  { role: 'user', content: 'Hello' },
9
10
  { role: 'assistant', content: 'Hi there!' },
10
11
  ];