@librechat/agents 3.0.27 → 3.0.29

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.
@@ -363,6 +363,78 @@ function _formatContent(message: BaseMessage) {
363
363
  return content;
364
364
  } else {
365
365
  const contentBlocks = content.map((contentPart) => {
366
+ /**
367
+ * Handle malformed blocks that have server tool fields mixed with text type.
368
+ * These can occur when server_tool_use blocks get mislabeled during aggregation.
369
+ * Correct their type ONLY if we can confirm it's a server tool by checking the ID prefix.
370
+ * Anthropic needs both server_tool_use and web_search_tool_result blocks for citations to work.
371
+ */
372
+ if (
373
+ 'id' in contentPart &&
374
+ 'name' in contentPart &&
375
+ 'input' in contentPart &&
376
+ contentPart.type === 'text'
377
+ ) {
378
+ const rawPart = contentPart as Record<string, unknown>;
379
+ const id = rawPart.id as string;
380
+
381
+ // Only correct if this is definitely a server tool (ID starts with 'srvtoolu_')
382
+ if (id && id.startsWith('srvtoolu_')) {
383
+ let input = rawPart.input;
384
+
385
+ // Ensure input is an object
386
+ if (typeof input === 'string') {
387
+ try {
388
+ input = JSON.parse(input);
389
+ } catch {
390
+ input = {};
391
+ }
392
+ }
393
+
394
+ const corrected: AnthropicServerToolUseBlockParam = {
395
+ type: 'server_tool_use',
396
+ id,
397
+ name: 'web_search',
398
+ input: input as Record<string, unknown>,
399
+ };
400
+
401
+ return corrected;
402
+ }
403
+
404
+ // If it's not a server tool, skip it (return null to filter it out)
405
+ return null;
406
+ }
407
+
408
+ /**
409
+ * Handle malformed web_search_tool_result blocks marked as text.
410
+ * These have tool_use_id and nested content arrays - fix their type instead of filtering.
411
+ * Only correct if we can confirm it's a web search result by checking the tool_use_id prefix.
412
+ */
413
+ if (
414
+ 'tool_use_id' in contentPart &&
415
+ 'content' in contentPart &&
416
+ Array.isArray(contentPart.content) &&
417
+ contentPart.type === 'text'
418
+ ) {
419
+ const rawPart = contentPart as Record<string, unknown>;
420
+ const toolUseId = rawPart.tool_use_id as string;
421
+
422
+ // Only correct if this is definitely a server tool result (tool_use_id starts with 'srvtoolu_')
423
+ if (toolUseId && toolUseId.startsWith('srvtoolu_')) {
424
+ const corrected: AnthropicWebSearchToolResultBlockParam = {
425
+ type: 'web_search_tool_result',
426
+ tool_use_id: toolUseId,
427
+ content:
428
+ rawPart.content as AnthropicWebSearchToolResultBlockParam['content'],
429
+ };
430
+
431
+ return corrected;
432
+ }
433
+
434
+ // If it's not a server tool result, skip it (return null to filter it out)
435
+ return null;
436
+ }
437
+
366
438
  if (isDataContentBlock(contentPart)) {
367
439
  return convertToProviderContentBlock(
368
440
  contentPart,
@@ -459,6 +531,14 @@ function _formatContent(message: BaseMessage) {
459
531
  }
460
532
  }
461
533
 
534
+ /**
535
+ * For multi-turn conversations with citations, we must preserve ALL blocks
536
+ * including server_tool_use, web_search_tool_result, and web_search_result.
537
+ * Citations reference search results by index, so filtering changes indices and breaks references.
538
+ *
539
+ * The ToolNode already handles skipping server tool invocations via the srvtoolu_ prefix check.
540
+ */
541
+
462
542
  // TODO: Fix when SDK types are fixed
463
543
  return {
464
544
  ...contentPartCopy,
@@ -487,10 +567,14 @@ function _formatContent(message: BaseMessage) {
487
567
  input: contentPart.functionCall.args,
488
568
  };
489
569
  } else {
570
+ console.error(
571
+ 'Unsupported content part:',
572
+ JSON.stringify(contentPart, null, 2)
573
+ );
490
574
  throw new Error('Unsupported message content format');
491
575
  }
492
576
  });
493
- return contentBlocks;
577
+ return contentBlocks.filter((block) => block !== null);
494
578
  }
495
579
  }
496
580
 
@@ -545,14 +629,15 @@ export function _convertMessagesToAnthropicPayload(
545
629
  }
546
630
  } else {
547
631
  const { content } = message;
548
- const hasMismatchedToolCalls = !message.tool_calls.every((toolCall) =>
549
- content.find(
550
- (contentPart) =>
551
- (contentPart.type === 'tool_use' ||
552
- contentPart.type === 'input_json_delta' ||
553
- contentPart.type === 'server_tool_use') &&
554
- contentPart.id === toolCall.id
555
- )
632
+ const hasMismatchedToolCalls = !message.tool_calls.every(
633
+ (toolCall) =>
634
+ !!content.find(
635
+ (contentPart) =>
636
+ (contentPart.type === 'tool_use' ||
637
+ contentPart.type === 'input_json_delta' ||
638
+ contentPart.type === 'server_tool_use') &&
639
+ contentPart.id === toolCall.id
640
+ )
556
641
  );
557
642
  if (hasMismatchedToolCalls) {
558
643
  console.warn(
@@ -0,0 +1,162 @@
1
+ /* eslint-disable no-console */
2
+ // src/scripts/cli.ts
3
+ import { config } from 'dotenv';
4
+ config();
5
+ import { HumanMessage, BaseMessage } from '@langchain/core/messages';
6
+ import type * as t from '@/types';
7
+ import { ChatModelStreamHandler, createContentAggregator } from '@/stream';
8
+ import { ToolEndHandler, ModelEndHandler } from '@/events';
9
+ import { Calculator } from '@/tools/Calculator';
10
+
11
+ import { getArgs } from '@/scripts/args';
12
+ import { Run } from '@/run';
13
+ import { GraphEvents, Callback, Providers } from '@/common';
14
+ import { getLLMConfig } from '@/utils/llmConfig';
15
+
16
+ const conversationHistory: BaseMessage[] = [];
17
+ let _contentParts: (t.MessageContentComplex | undefined)[] = [];
18
+ async function testStandardStreaming(): Promise<void> {
19
+ const { userName, location, currentDate } = await getArgs();
20
+ const { contentParts, aggregateContent } = createContentAggregator();
21
+ _contentParts = contentParts;
22
+ const customHandlers = {
23
+ [GraphEvents.TOOL_END]: new ToolEndHandler(),
24
+ [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
25
+ [GraphEvents.CHAT_MODEL_STREAM]: new ChatModelStreamHandler(),
26
+ [GraphEvents.ON_RUN_STEP_COMPLETED]: {
27
+ handle: (
28
+ event: GraphEvents.ON_RUN_STEP_COMPLETED,
29
+ data: t.StreamEventData
30
+ ): void => {
31
+ console.log('====== ON_RUN_STEP_COMPLETED ======');
32
+ // console.dir(data, { depth: null });
33
+ aggregateContent({
34
+ event,
35
+ data: data as unknown as { result: t.ToolEndEvent },
36
+ });
37
+ },
38
+ },
39
+ [GraphEvents.ON_RUN_STEP]: {
40
+ handle: (
41
+ event: GraphEvents.ON_RUN_STEP,
42
+ data: t.StreamEventData
43
+ ): void => {
44
+ console.log('====== ON_RUN_STEP ======');
45
+ console.dir(data, { depth: null });
46
+ aggregateContent({ event, data: data as t.RunStep });
47
+ },
48
+ },
49
+ [GraphEvents.ON_RUN_STEP_DELTA]: {
50
+ handle: (
51
+ event: GraphEvents.ON_RUN_STEP_DELTA,
52
+ data: t.StreamEventData
53
+ ): void => {
54
+ console.log('====== ON_RUN_STEP_DELTA ======');
55
+ console.dir(data, { depth: null });
56
+ aggregateContent({ event, data: data as t.RunStepDeltaEvent });
57
+ },
58
+ },
59
+ [GraphEvents.ON_MESSAGE_DELTA]: {
60
+ handle: (
61
+ event: GraphEvents.ON_MESSAGE_DELTA,
62
+ data: t.StreamEventData
63
+ ): void => {
64
+ // console.log('====== ON_MESSAGE_DELTA ======');
65
+ // console.dir(data, { depth: null });
66
+ aggregateContent({ event, data: data as t.MessageDeltaEvent });
67
+ },
68
+ },
69
+ [GraphEvents.TOOL_START]: {
70
+ handle: (
71
+ _event: string,
72
+ data: t.StreamEventData,
73
+ metadata?: Record<string, unknown>
74
+ ): void => {
75
+ console.log('====== TOOL_START ======');
76
+ // console.dir(data, { depth: null });
77
+ },
78
+ },
79
+ };
80
+
81
+ const llmConfig = getLLMConfig(
82
+ Providers.ANTHROPIC
83
+ ) as t.AnthropicClientOptions & t.SharedLLMConfig;
84
+ llmConfig.model = 'claude-haiku-4-5';
85
+
86
+ const run = await Run.create<t.IState>({
87
+ runId: 'test-run-id',
88
+ graphConfig: {
89
+ type: 'standard',
90
+ llmConfig,
91
+ tools: [
92
+ {
93
+ type: 'web_search_20250305',
94
+ name: 'web_search',
95
+ max_uses: 5,
96
+ },
97
+ new Calculator(),
98
+ ],
99
+ instructions: 'You are a friendly AI assistant.',
100
+ // additional_instructions: `Always address the user by their name. The user's name is ${userName} and they are located in ${location}.`,
101
+ },
102
+ returnContent: true,
103
+ customHandlers,
104
+ });
105
+
106
+ const config = {
107
+ configurable: {
108
+ provider: Providers.ANTHROPIC,
109
+ thread_id: 'conversation-num-1',
110
+ },
111
+ streamMode: 'values',
112
+ version: 'v2' as const,
113
+ };
114
+
115
+ console.log('Test 1: Web search + calculator (simultaneous tool test)');
116
+
117
+ // const userMessage = `
118
+ // Make a search for the weather in ${location} today, which is ${currentDate}.
119
+ // Before making the search, please let me know what you're about to do, then immediately start searching without hesitation.
120
+ // Make sure to always refer to me by name, which is ${userName}.
121
+ // After giving me a thorough summary, tell me a joke about the weather forecast we went over.
122
+ // `;
123
+ // const userMessage = 'Are massage guns good?';
124
+ // const userMessage = 'What is functional programming?';
125
+ // const userMessage = "Get me today's trending news.";
126
+ // const userMessage = "search recent italy earthquake volcano activity";
127
+ // const userMessage =
128
+ // "use 'Trump' as the exact search query and tell me what you find.";
129
+ const userMessage =
130
+ 'Can you search the web for the current population of Tokyo, and also calculate what 15% of that population would be? Do both at the same time.';
131
+
132
+ conversationHistory.push(new HumanMessage(userMessage));
133
+
134
+ const inputs = {
135
+ messages: conversationHistory,
136
+ };
137
+ const finalContentParts = await run.processStream(inputs, config);
138
+ const finalMessages = run.getRunMessages();
139
+ if (finalMessages) {
140
+ conversationHistory.push(...finalMessages);
141
+ console.dir(conversationHistory, { depth: null });
142
+ }
143
+ // console.dir(finalContentParts, { depth: null });
144
+ console.log('\n\n====================\n\n');
145
+ // console.dir(contentParts, { depth: null });
146
+ }
147
+
148
+ process.on('unhandledRejection', (reason, promise) => {
149
+ console.error('Unhandled Rejection at:', promise, 'reason:', reason);
150
+ console.log('Content Parts:');
151
+ console.dir(_contentParts, { depth: null });
152
+ process.exit(1);
153
+ });
154
+
155
+ testStandardStreaming().catch((err) => {
156
+ console.error(err);
157
+ console.log('Conversation history:');
158
+ console.dir(conversationHistory, { depth: null });
159
+ console.log('Content Parts:');
160
+ console.dir(_contentParts, { depth: null });
161
+ process.exit(1);
162
+ });
@@ -306,6 +306,73 @@ describe(`${capitalizeFirstLetter(provider)} Streaming Tests`, () => {
306
306
  expect(onRunStepSpy.mock.calls.length).toBeGreaterThan(0);
307
307
  });
308
308
 
309
+ test(`${capitalizeFirstLetter(provider)}: should handle parallel tool usage (web search + calculator)`, async () => {
310
+ const llmConfig = getLLMConfig(provider);
311
+ const customHandlers = setupCustomHandlers();
312
+
313
+ run = await Run.create<t.IState>({
314
+ runId: 'test-parallel-tools',
315
+ graphConfig: {
316
+ type: 'standard',
317
+ llmConfig,
318
+ tools: [
319
+ {
320
+ type: 'web_search_20250305',
321
+ name: 'web_search',
322
+ max_uses: 5,
323
+ },
324
+ new Calculator(),
325
+ ],
326
+ instructions: 'You are a helpful AI assistant.',
327
+ },
328
+ returnContent: true,
329
+ customHandlers,
330
+ });
331
+
332
+ // Use the same query as the edge case script to test actual parallel tool usage
333
+ const userMessage =
334
+ 'Can you search the web for the current population of Tokyo, and also calculate what 15% of that population would be? Do both at the same time.';
335
+ conversationHistory = [];
336
+ conversationHistory.push(new HumanMessage(userMessage));
337
+
338
+ const inputs = {
339
+ messages: conversationHistory,
340
+ };
341
+
342
+ // This should complete without errors despite using both server tools and regular tools in parallel
343
+ const finalContentParts = await run.processStream(inputs, config);
344
+ expect(finalContentParts).toBeDefined();
345
+
346
+ const finalMessages = run.getRunMessages();
347
+ expect(finalMessages).toBeDefined();
348
+ expect(finalMessages?.length).toBeGreaterThan(0);
349
+
350
+ const hasWebSearch = contentParts.some(
351
+ (part) =>
352
+ !!(
353
+ part.type === 'tool_call' &&
354
+ part.tool_call?.name === 'web_search' &&
355
+ part.tool_call?.id?.startsWith('srvtoolu_') === true
356
+ )
357
+ );
358
+ const hasCalculator = contentParts.some(
359
+ (part) =>
360
+ !!(
361
+ part.type === 'tool_call' &&
362
+ part.tool_call?.name === 'calculator' &&
363
+ part.tool_call?.id?.startsWith('toolu_') === true
364
+ )
365
+ );
366
+
367
+ // Both tools should have been used for this query
368
+ expect(hasWebSearch).toBe(true);
369
+ expect(hasCalculator).toBe(true);
370
+
371
+ console.log(
372
+ `${capitalizeFirstLetter(provider)} parallel tools test: web_search (server tool) + calculator (regular tool) both used successfully`
373
+ );
374
+ });
375
+
309
376
  test('should handle errors appropriately', async () => {
310
377
  // Test error scenarios
311
378
  await expect(async () => {
package/src/stream.ts CHANGED
@@ -155,7 +155,10 @@ export class ChatModelStreamHandler implements t.EventHandler {
155
155
  chunk.tool_calls.length > 0 &&
156
156
  chunk.tool_calls.every(
157
157
  (tc) =>
158
- tc.id != null && tc.id !== '' && tc.name != null && tc.name !== ''
158
+ tc.id != null &&
159
+ tc.id !== '' &&
160
+ (tc as Partial<ToolCall>).name != null &&
161
+ tc.name !== ''
159
162
  )
160
163
  ) {
161
164
  hasToolCalls = true;
@@ -201,7 +201,18 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
201
201
 
202
202
  outputs = await Promise.all(
203
203
  aiMessage.tool_calls
204
- ?.filter((call) => call.id == null || !toolMessageIds.has(call.id))
204
+ ?.filter((call) => {
205
+ /**
206
+ * Filter out:
207
+ * 1. Already processed tool calls (present in toolMessageIds)
208
+ * 2. Server tool calls (e.g., web_search with IDs starting with 'srvtoolu_')
209
+ * which are executed by the provider's API and don't require invocation
210
+ */
211
+ return (
212
+ (call.id == null || !toolMessageIds.has(call.id)) &&
213
+ !(call.id?.startsWith('srvtoolu_') ?? false)
214
+ );
215
+ })
205
216
  .map((call) => this.runTool(call, config)) ?? []
206
217
  );
207
218
  }