mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-19 01:40:15 +01:00
🔧 fix: Await MCP Instructions and Filter Malformed Tool Calls (#10485)
* fix: Await MCP instructions formatting in AgentClient * fix: don't render or aggregate malformed tool calls * fix: implement filter for malformed tool call content parts and add tests
This commit is contained in:
parent
aff3cd3667
commit
cabc8afeac
10 changed files with 467 additions and 9 deletions
200
packages/api/src/utils/content.spec.ts
Normal file
200
packages/api/src/utils/content.spec.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { ContentTypes, ToolCallTypes } from 'librechat-data-provider';
|
||||
import type { Agents, PartMetadata, TMessageContentParts } from 'librechat-data-provider';
|
||||
import type { ToolCall } from '@langchain/core/messages/tool';
|
||||
import { filterMalformedContentParts } from './content';
|
||||
|
||||
describe('filterMalformedContentParts', () => {
|
||||
describe('basic filtering', () => {
|
||||
it('should keep valid tool_call content parts', () => {
|
||||
const parts: TMessageContentParts[] = [
|
||||
{
|
||||
type: ContentTypes.TOOL_CALL,
|
||||
tool_call: {
|
||||
id: 'test-id',
|
||||
name: 'test_function',
|
||||
type: ToolCallTypes.TOOL_CALL,
|
||||
args: '{}',
|
||||
progress: 1,
|
||||
output: 'result',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = filterMalformedContentParts(parts);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual(parts[0]);
|
||||
});
|
||||
|
||||
it('should filter out malformed tool_call content parts without tool_call property', () => {
|
||||
const parts: TMessageContentParts[] = [
|
||||
{ type: ContentTypes.TOOL_CALL } as TMessageContentParts,
|
||||
];
|
||||
|
||||
const result = filterMalformedContentParts(parts);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should keep other content types unchanged', () => {
|
||||
const parts: TMessageContentParts[] = [
|
||||
{ type: ContentTypes.TEXT, text: 'Hello world' },
|
||||
{ type: ContentTypes.THINK, think: 'Thinking...' },
|
||||
];
|
||||
|
||||
const result = filterMalformedContentParts(parts);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toEqual(parts);
|
||||
});
|
||||
|
||||
it('should filter out null or undefined parts', () => {
|
||||
const parts = [
|
||||
{ type: ContentTypes.TEXT, text: 'Valid' },
|
||||
null,
|
||||
undefined,
|
||||
{ type: ContentTypes.TEXT, text: 'Also valid' },
|
||||
] as TMessageContentParts[];
|
||||
|
||||
const result = filterMalformedContentParts(parts);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toHaveProperty('text', 'Valid');
|
||||
expect(result[1]).toHaveProperty('text', 'Also valid');
|
||||
});
|
||||
|
||||
it('should return non-array input unchanged', () => {
|
||||
const notAnArray = { some: 'object' };
|
||||
const result = filterMalformedContentParts(notAnArray);
|
||||
expect(result).toBe(notAnArray);
|
||||
});
|
||||
});
|
||||
|
||||
describe('real-life example with multiple tool calls', () => {
|
||||
it('should filter out malformed tool_call entries from actual MCP response', () => {
|
||||
const parts: TMessageContentParts[] = [
|
||||
{
|
||||
type: ContentTypes.THINK,
|
||||
think:
|
||||
'The user is asking for 10 different time zones, similar to what would be displayed in a stock trading room floor.',
|
||||
},
|
||||
{
|
||||
type: ContentTypes.TEXT,
|
||||
text: '# Global Market Times\n\nShowing current time in 10 major financial centers:',
|
||||
tool_call_ids: ['tooluse_Yjfib8PoRXCeCcHRH0JqCw'],
|
||||
},
|
||||
{
|
||||
type: ContentTypes.TOOL_CALL,
|
||||
tool_call: {
|
||||
id: 'tooluse_Yjfib8PoRXCeCcHRH0JqCw',
|
||||
name: 'get_current_time_mcp_time',
|
||||
args: '{"timezone":"America/New_York"}',
|
||||
type: ToolCallTypes.TOOL_CALL,
|
||||
progress: 1,
|
||||
output: '{"timezone":"America/New_York","datetime":"2025-11-13T13:43:17-05:00"}',
|
||||
},
|
||||
},
|
||||
{ type: ContentTypes.TOOL_CALL } as TMessageContentParts,
|
||||
{
|
||||
type: ContentTypes.TOOL_CALL,
|
||||
tool_call: {
|
||||
id: 'tooluse_CPsGv9kXTrewVkcO7BEYIg',
|
||||
name: 'get_current_time_mcp_time',
|
||||
args: '{"timezone":"Europe/London"}',
|
||||
type: ToolCallTypes.TOOL_CALL,
|
||||
progress: 1,
|
||||
output: '{"timezone":"Europe/London","datetime":"2025-11-13T18:43:19+00:00"}',
|
||||
},
|
||||
},
|
||||
{ type: ContentTypes.TOOL_CALL } as TMessageContentParts,
|
||||
{
|
||||
type: ContentTypes.TOOL_CALL,
|
||||
tool_call: {
|
||||
id: 'tooluse_5jihRbd4TDWCGebwmAUlfQ',
|
||||
name: 'get_current_time_mcp_time',
|
||||
args: '{"timezone":"Asia/Tokyo"}',
|
||||
type: ToolCallTypes.TOOL_CALL,
|
||||
progress: 1,
|
||||
output: '{"timezone":"Asia/Tokyo","datetime":"2025-11-14T03:43:21+09:00"}',
|
||||
},
|
||||
},
|
||||
{ type: ContentTypes.TOOL_CALL } as TMessageContentParts,
|
||||
{ type: ContentTypes.TOOL_CALL } as TMessageContentParts,
|
||||
{ type: ContentTypes.TOOL_CALL } as TMessageContentParts,
|
||||
{
|
||||
type: ContentTypes.TEXT,
|
||||
text: '## Major Financial Markets Clock:\n\n| Market | Local Time | Day |',
|
||||
},
|
||||
];
|
||||
|
||||
const result = filterMalformedContentParts(parts);
|
||||
|
||||
expect(result).toHaveLength(6);
|
||||
|
||||
expect(result[0].type).toBe(ContentTypes.THINK);
|
||||
expect(result[1].type).toBe(ContentTypes.TEXT);
|
||||
expect(result[2].type).toBe(ContentTypes.TOOL_CALL);
|
||||
expect(result[3].type).toBe(ContentTypes.TOOL_CALL);
|
||||
expect(result[4].type).toBe(ContentTypes.TOOL_CALL);
|
||||
expect(result[5].type).toBe(ContentTypes.TEXT);
|
||||
|
||||
const toolCalls = result.filter((part) => part.type === ContentTypes.TOOL_CALL);
|
||||
expect(toolCalls).toHaveLength(3);
|
||||
|
||||
toolCalls.forEach((toolCall) => {
|
||||
if (toolCall.type === ContentTypes.TOOL_CALL) {
|
||||
expect(toolCall.tool_call).toBeDefined();
|
||||
expect(toolCall.tool_call).toHaveProperty('id');
|
||||
expect(toolCall.tool_call).toHaveProperty('name');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
const result = filterMalformedContentParts([]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle array with only malformed tool calls', () => {
|
||||
const parts = [
|
||||
{ type: ContentTypes.TOOL_CALL },
|
||||
{ type: ContentTypes.TOOL_CALL },
|
||||
{ type: ContentTypes.TOOL_CALL },
|
||||
] as TMessageContentParts[];
|
||||
|
||||
const result = filterMalformedContentParts(parts);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should filter out tool_call with null tool_call property', () => {
|
||||
const parts = [
|
||||
{ type: ContentTypes.TOOL_CALL, tool_call: null as unknown as ToolCall },
|
||||
] as TMessageContentParts[];
|
||||
|
||||
const result = filterMalformedContentParts(parts);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should filter out tool_call with non-object tool_call property', () => {
|
||||
const parts = [
|
||||
{
|
||||
type: ContentTypes.TOOL_CALL,
|
||||
tool_call: 'not an object' as unknown as ToolCall & PartMetadata,
|
||||
},
|
||||
] as TMessageContentParts[];
|
||||
|
||||
const result = filterMalformedContentParts(parts);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should keep tool_call with empty object as tool_call', () => {
|
||||
const parts: TMessageContentParts[] = [
|
||||
{
|
||||
type: ContentTypes.TOOL_CALL,
|
||||
tool_call: {} as unknown as Agents.ToolCall & PartMetadata,
|
||||
},
|
||||
];
|
||||
|
||||
const result = filterMalformedContentParts(parts);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
46
packages/api/src/utils/content.ts
Normal file
46
packages/api/src/utils/content.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import type { TMessageContentParts } from 'librechat-data-provider';
|
||||
|
||||
/**
|
||||
* Filters out malformed tool call content parts that don't have the required tool_call property.
|
||||
* This handles edge cases where tool_call content parts may be created with only a type property
|
||||
* but missing the actual tool_call data.
|
||||
*
|
||||
* @param contentParts - Array of content parts to filter
|
||||
* @returns Filtered array with malformed tool calls removed
|
||||
*
|
||||
* @example
|
||||
* // Removes malformed tool_call without the tool_call property
|
||||
* const parts = [
|
||||
* { type: 'tool_call', tool_call: { id: '123', name: 'test' } }, // valid - kept
|
||||
* { type: 'tool_call' }, // invalid - filtered out
|
||||
* { type: 'text', text: 'Hello' }, // valid - kept (other types pass through)
|
||||
* ];
|
||||
* const filtered = filterMalformedContentParts(parts);
|
||||
* // Returns all parts except the malformed tool_call
|
||||
*/
|
||||
export function filterMalformedContentParts(
|
||||
contentParts: TMessageContentParts[],
|
||||
): TMessageContentParts[];
|
||||
export function filterMalformedContentParts<T>(contentParts: T): T;
|
||||
export function filterMalformedContentParts<T>(
|
||||
contentParts: T | TMessageContentParts[],
|
||||
): T | TMessageContentParts[] {
|
||||
if (!Array.isArray(contentParts)) {
|
||||
return contentParts;
|
||||
}
|
||||
|
||||
return contentParts.filter((part) => {
|
||||
if (!part || typeof part !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { type } = part;
|
||||
|
||||
if (type === ContentTypes.TOOL_CALL) {
|
||||
return 'tool_call' in part && part.tool_call != null && typeof part.tool_call === 'object';
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
export * from './axios';
|
||||
export * from './azure';
|
||||
export * from './common';
|
||||
export * from './content';
|
||||
export * from './email';
|
||||
export * from './env';
|
||||
export * from './events';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue