@librechat/agents 3.0.17 → 3.0.18

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.
@@ -0,0 +1,620 @@
1
+ // src/specs/thinking-handoff.test.ts
2
+ import { HumanMessage, ToolMessage } from '@langchain/core/messages';
3
+ import type { ToolCall } from '@langchain/core/messages/tool';
4
+ import type { RunnableConfig } from '@langchain/core/runnables';
5
+ import type * as t from '@/types';
6
+ import { Providers, Constants } from '@/common';
7
+ import { StandardGraph } from '@/graphs/Graph';
8
+ import { Run } from '@/run';
9
+
10
+ /**
11
+ * Test suite for Thinking-Enabled Agent Handoff Edge Case
12
+ *
13
+ * Tests the specific edge case where:
14
+ * - An agent without thinking blocks (e.g., OpenAI) makes a tool call
15
+ * - Control is handed off to an agent with thinking enabled (e.g., Anthropic/Bedrock)
16
+ * - The system should handle the transition without errors
17
+ *
18
+ * Background:
19
+ * When Anthropic's extended thinking is enabled, the API requires that any assistant
20
+ * message with tool_use content must start with a thinking or redacted_thinking block.
21
+ * When switching from a non-thinking agent to a thinking-enabled agent, previous
22
+ * messages may not have these blocks, causing API errors.
23
+ *
24
+ * Solution:
25
+ * The ensureThinkingBlockInMessages() function converts AI messages with tool calls
26
+ * (that lack thinking blocks) into HumanMessages with buffer strings, avoiding the
27
+ * thinking block requirement while preserving context.
28
+ */
29
+ describe('Thinking-Enabled Agent Handoff Tests', () => {
30
+ jest.setTimeout(30000);
31
+
32
+ const createTestConfig = (
33
+ agents: t.AgentInputs[],
34
+ edges: t.GraphEdge[]
35
+ ): t.RunConfig => ({
36
+ runId: `thinking-handoff-test-${Date.now()}-${Math.random()}`,
37
+ graphConfig: {
38
+ type: 'multi-agent',
39
+ agents,
40
+ edges,
41
+ },
42
+ returnContent: true,
43
+ });
44
+
45
+ describe('OpenAI to Anthropic with Thinking', () => {
46
+ it('should successfully handoff from OpenAI to Anthropic with thinking enabled', async () => {
47
+ const agents: t.AgentInputs[] = [
48
+ {
49
+ agentId: 'supervisor',
50
+ provider: Providers.OPENAI,
51
+ clientOptions: {
52
+ modelName: 'gpt-4o-mini',
53
+ apiKey: 'test-key',
54
+ },
55
+ instructions:
56
+ 'You are a supervisor. Use transfer_to_specialist when asked.',
57
+ maxContextTokens: 8000,
58
+ },
59
+ {
60
+ agentId: 'specialist',
61
+ provider: Providers.ANTHROPIC,
62
+ clientOptions: {
63
+ modelName: 'claude-3-7-sonnet-20250219',
64
+ apiKey: 'test-key',
65
+ thinking: {
66
+ type: 'enabled',
67
+ budget_tokens: 2000,
68
+ },
69
+ },
70
+ instructions: 'You are a specialist. Provide detailed answers.',
71
+ maxContextTokens: 8000,
72
+ },
73
+ ];
74
+
75
+ const edges: t.GraphEdge[] = [
76
+ {
77
+ from: 'supervisor',
78
+ to: 'specialist',
79
+ edgeType: 'handoff',
80
+ description: 'Transfer to specialist for detailed analysis',
81
+ },
82
+ ];
83
+
84
+ const run = await Run.create(createTestConfig(agents, edges));
85
+
86
+ // Simulate supervisor using handoff tool
87
+ run.Graph?.overrideTestModel(
88
+ [
89
+ 'Let me transfer you to our specialist',
90
+ 'As a specialist, let me analyze this carefully...',
91
+ ],
92
+ 10,
93
+ [
94
+ {
95
+ id: 'tool_call_1',
96
+ name: `${Constants.LC_TRANSFER_TO_}specialist`,
97
+ args: {},
98
+ } as ToolCall,
99
+ ]
100
+ );
101
+
102
+ const messages = [new HumanMessage('I need expert analysis')];
103
+
104
+ const config: Partial<RunnableConfig> & {
105
+ version: 'v1' | 'v2';
106
+ streamMode: string;
107
+ } = {
108
+ configurable: {
109
+ thread_id: 'test-thinking-handoff-thread',
110
+ },
111
+ streamMode: 'values',
112
+ version: 'v2' as const,
113
+ };
114
+
115
+ // Should not throw despite thinking requirement
116
+ await expect(
117
+ run.processStream({ messages }, config)
118
+ ).resolves.not.toThrow();
119
+
120
+ const finalMessages = run.getRunMessages();
121
+ expect(finalMessages).toBeDefined();
122
+ expect(finalMessages!.length).toBeGreaterThan(1);
123
+
124
+ // Should have successful handoff
125
+ const toolMessages = finalMessages!.filter(
126
+ (msg) => msg.getType() === 'tool'
127
+ ) as ToolMessage[];
128
+
129
+ const handoffMessage = toolMessages.find(
130
+ (msg) => msg.name === `${Constants.LC_TRANSFER_TO_}specialist`
131
+ );
132
+ expect(handoffMessage).toBeDefined();
133
+ expect(handoffMessage?.content).toContain('transferred to specialist');
134
+ });
135
+
136
+ it('should convert tool sequence to HumanMessage for thinking-enabled agent', async () => {
137
+ const agents: t.AgentInputs[] = [
138
+ {
139
+ agentId: 'agent_a',
140
+ provider: Providers.OPENAI,
141
+ clientOptions: {
142
+ modelName: 'gpt-4o-mini',
143
+ apiKey: 'test-key',
144
+ },
145
+ instructions: 'You are agent A',
146
+ maxContextTokens: 8000,
147
+ },
148
+ {
149
+ agentId: 'agent_b',
150
+ provider: Providers.ANTHROPIC,
151
+ clientOptions: {
152
+ modelName: 'claude-3-7-sonnet-20250219',
153
+ apiKey: 'test-key',
154
+ thinking: {
155
+ type: 'enabled',
156
+ budget_tokens: 2000,
157
+ },
158
+ },
159
+ instructions: 'You are agent B with thinking enabled',
160
+ maxContextTokens: 8000,
161
+ },
162
+ ];
163
+
164
+ const edges: t.GraphEdge[] = [
165
+ {
166
+ from: 'agent_a',
167
+ to: 'agent_b',
168
+ edgeType: 'handoff',
169
+ },
170
+ ];
171
+
172
+ const run = await Run.create(createTestConfig(agents, edges));
173
+
174
+ // Check that agent B's context is set up correctly
175
+ const agentBContext = (run.Graph as StandardGraph).agentContexts.get(
176
+ 'agent_b'
177
+ );
178
+ expect(agentBContext).toBeDefined();
179
+
180
+ // Verify thinking is enabled
181
+ const thinkingConfig = (
182
+ agentBContext?.clientOptions as t.AnthropicClientOptions
183
+ ).thinking;
184
+ expect(thinkingConfig).toBeDefined();
185
+ expect(thinkingConfig?.type).toBe('enabled');
186
+ });
187
+ });
188
+
189
+ describe('Bedrock with Thinking', () => {
190
+ it('should handle handoff from Bedrock without thinking to Bedrock with thinking', async () => {
191
+ const agents: t.AgentInputs[] = [
192
+ {
193
+ agentId: 'coordinator',
194
+ provider: Providers.BEDROCK,
195
+ clientOptions: {
196
+ region: 'us-east-1',
197
+ model: 'anthropic.claude-3-5-haiku-20241022-v1:0',
198
+ // No thinking config
199
+ },
200
+ instructions: 'You are a coordinator',
201
+ maxContextTokens: 8000,
202
+ },
203
+ {
204
+ agentId: 'analyst',
205
+ provider: Providers.BEDROCK,
206
+ clientOptions: {
207
+ region: 'us-east-1',
208
+ model: 'us.anthropic.claude-3-7-sonnet-20250219-v1:0',
209
+ additionalModelRequestFields: {
210
+ thinking: {
211
+ type: 'enabled',
212
+ budget_tokens: 2000,
213
+ },
214
+ },
215
+ },
216
+ instructions: 'You are an analyst with extended thinking',
217
+ maxContextTokens: 8000,
218
+ },
219
+ ];
220
+
221
+ const edges: t.GraphEdge[] = [
222
+ {
223
+ from: 'coordinator',
224
+ to: 'analyst',
225
+ edgeType: 'handoff',
226
+ description: 'Transfer to analyst for deep analysis',
227
+ },
228
+ ];
229
+
230
+ const run = await Run.create(createTestConfig(agents, edges));
231
+
232
+ run.Graph?.overrideTestModel(
233
+ ['Transferring to analyst', 'Deep analysis results...'],
234
+ 10,
235
+ [
236
+ {
237
+ id: 'tool_call_1',
238
+ name: `${Constants.LC_TRANSFER_TO_}analyst`,
239
+ args: {},
240
+ } as ToolCall,
241
+ ]
242
+ );
243
+
244
+ const messages = [new HumanMessage('Analyze this data')];
245
+
246
+ const config: Partial<RunnableConfig> & {
247
+ version: 'v1' | 'v2';
248
+ streamMode: string;
249
+ } = {
250
+ configurable: {
251
+ thread_id: 'test-bedrock-thinking-thread',
252
+ },
253
+ streamMode: 'values',
254
+ version: 'v2' as const,
255
+ };
256
+
257
+ await expect(
258
+ run.processStream({ messages }, config)
259
+ ).resolves.not.toThrow();
260
+
261
+ const finalMessages = run.getRunMessages();
262
+ expect(finalMessages).toBeDefined();
263
+ });
264
+
265
+ it('should verify Bedrock thinking configuration is properly detected', async () => {
266
+ const agents: t.AgentInputs[] = [
267
+ {
268
+ agentId: 'agent_a',
269
+ provider: Providers.OPENAI,
270
+ clientOptions: {
271
+ modelName: 'gpt-4o-mini',
272
+ apiKey: 'test-key',
273
+ },
274
+ instructions: 'You are agent A',
275
+ maxContextTokens: 8000,
276
+ },
277
+ {
278
+ agentId: 'agent_b',
279
+ provider: Providers.BEDROCK,
280
+ clientOptions: {
281
+ region: 'us-east-1',
282
+ model: 'us.anthropic.claude-3-7-sonnet-20250219-v1:0',
283
+ additionalModelRequestFields: {
284
+ thinking: {
285
+ type: 'enabled',
286
+ budget_tokens: 3000,
287
+ },
288
+ },
289
+ },
290
+ instructions: 'You are agent B with Bedrock thinking',
291
+ maxContextTokens: 8000,
292
+ },
293
+ ];
294
+
295
+ const edges: t.GraphEdge[] = [
296
+ {
297
+ from: 'agent_a',
298
+ to: 'agent_b',
299
+ edgeType: 'handoff',
300
+ },
301
+ ];
302
+
303
+ const run = await Run.create(createTestConfig(agents, edges));
304
+
305
+ const agentBContext = (run.Graph as StandardGraph).agentContexts.get(
306
+ 'agent_b'
307
+ );
308
+ expect(agentBContext).toBeDefined();
309
+ expect(agentBContext?.provider).toBe(Providers.BEDROCK);
310
+
311
+ // Verify thinking configuration in additionalModelRequestFields
312
+ const bedrockOptions =
313
+ agentBContext?.clientOptions as t.BedrockAnthropicInput;
314
+ expect(bedrockOptions.additionalModelRequestFields).toBeDefined();
315
+ expect(
316
+ bedrockOptions.additionalModelRequestFields?.thinking
317
+ ).toBeDefined();
318
+
319
+ const thinkingConfig = bedrockOptions.additionalModelRequestFields
320
+ ?.thinking as {
321
+ type: string;
322
+ budget_tokens: number;
323
+ };
324
+ expect(thinkingConfig.type).toBe('enabled');
325
+ expect(thinkingConfig.budget_tokens).toBe(3000);
326
+ });
327
+
328
+ it('should handle OpenAI to Bedrock with thinking handoff', async () => {
329
+ const agents: t.AgentInputs[] = [
330
+ {
331
+ agentId: 'supervisor',
332
+ provider: Providers.OPENAI,
333
+ clientOptions: {
334
+ modelName: 'gpt-4o-mini',
335
+ apiKey: 'test-key',
336
+ },
337
+ instructions: 'You are a supervisor',
338
+ maxContextTokens: 8000,
339
+ },
340
+ {
341
+ agentId: 'bedrock_specialist',
342
+ provider: Providers.BEDROCK,
343
+ clientOptions: {
344
+ region: 'us-east-1',
345
+ model: 'us.anthropic.claude-3-7-sonnet-20250219-v1:0',
346
+ additionalModelRequestFields: {
347
+ thinking: {
348
+ type: 'enabled',
349
+ budget_tokens: 2000,
350
+ },
351
+ },
352
+ },
353
+ instructions: 'You are a Bedrock specialist with thinking',
354
+ maxContextTokens: 8000,
355
+ },
356
+ ];
357
+
358
+ const edges: t.GraphEdge[] = [
359
+ {
360
+ from: 'supervisor',
361
+ to: 'bedrock_specialist',
362
+ edgeType: 'handoff',
363
+ description: 'Transfer to Bedrock specialist',
364
+ },
365
+ ];
366
+
367
+ const run = await Run.create(createTestConfig(agents, edges));
368
+
369
+ run.Graph?.overrideTestModel(['Transferring', 'Analysis complete'], 10, [
370
+ {
371
+ id: 'tool_call_1',
372
+ name: `${Constants.LC_TRANSFER_TO_}bedrock_specialist`,
373
+ args: {},
374
+ } as ToolCall,
375
+ ]);
376
+
377
+ const messages = [new HumanMessage('Analyze this')];
378
+
379
+ const config: Partial<RunnableConfig> & {
380
+ version: 'v1' | 'v2';
381
+ streamMode: string;
382
+ } = {
383
+ configurable: {
384
+ thread_id: 'test-openai-bedrock-thread',
385
+ },
386
+ streamMode: 'values',
387
+ version: 'v2' as const,
388
+ };
389
+
390
+ await expect(
391
+ run.processStream({ messages }, config)
392
+ ).resolves.not.toThrow();
393
+
394
+ const finalMessages = run.getRunMessages();
395
+ expect(finalMessages).toBeDefined();
396
+
397
+ const toolMessages = finalMessages!.filter(
398
+ (msg) => msg.getType() === 'tool'
399
+ ) as ToolMessage[];
400
+
401
+ const handoffMessage = toolMessages.find(
402
+ (msg) => msg.name === `${Constants.LC_TRANSFER_TO_}bedrock_specialist`
403
+ );
404
+ expect(handoffMessage).toBeDefined();
405
+ });
406
+ });
407
+
408
+ describe('Multiple Handoffs with Mixed Thinking Configurations', () => {
409
+ it('should handle chain of handoffs with varying thinking configurations', async () => {
410
+ const agents: t.AgentInputs[] = [
411
+ {
412
+ agentId: 'router',
413
+ provider: Providers.OPENAI,
414
+ clientOptions: {
415
+ modelName: 'gpt-4o-mini',
416
+ apiKey: 'test-key',
417
+ },
418
+ instructions: 'You route requests',
419
+ maxContextTokens: 8000,
420
+ },
421
+ {
422
+ agentId: 'processor',
423
+ provider: Providers.ANTHROPIC,
424
+ clientOptions: {
425
+ modelName: 'claude-haiku-4-5',
426
+ apiKey: 'test-key',
427
+ // No thinking
428
+ },
429
+ instructions: 'You process requests',
430
+ maxContextTokens: 8000,
431
+ },
432
+ {
433
+ agentId: 'reviewer',
434
+ provider: Providers.ANTHROPIC,
435
+ clientOptions: {
436
+ modelName: 'claude-3-7-sonnet-20250219',
437
+ apiKey: 'test-key',
438
+ thinking: {
439
+ type: 'enabled',
440
+ budget_tokens: 2000,
441
+ },
442
+ },
443
+ instructions: 'You review with deep thinking',
444
+ maxContextTokens: 8000,
445
+ },
446
+ ];
447
+
448
+ const edges: t.GraphEdge[] = [
449
+ {
450
+ from: 'router',
451
+ to: 'processor',
452
+ edgeType: 'handoff',
453
+ },
454
+ {
455
+ from: 'processor',
456
+ to: 'reviewer',
457
+ edgeType: 'handoff',
458
+ },
459
+ ];
460
+
461
+ const run = await Run.create(createTestConfig(agents, edges));
462
+
463
+ // Verify all agents are created with correct configurations
464
+ const routerContext = (run.Graph as StandardGraph).agentContexts.get(
465
+ 'router'
466
+ );
467
+ const processorContext = (run.Graph as StandardGraph).agentContexts.get(
468
+ 'processor'
469
+ );
470
+ const reviewerContext = (run.Graph as StandardGraph).agentContexts.get(
471
+ 'reviewer'
472
+ );
473
+
474
+ expect(routerContext).toBeDefined();
475
+ expect(processorContext).toBeDefined();
476
+ expect(reviewerContext).toBeDefined();
477
+
478
+ // Verify thinking configuration on reviewer
479
+ const reviewerThinking = (
480
+ reviewerContext?.clientOptions as t.AnthropicClientOptions
481
+ ).thinking;
482
+ expect(reviewerThinking).toBeDefined();
483
+ expect(reviewerThinking?.type).toBe('enabled');
484
+
485
+ // Verify handoff tools exist
486
+ expect(
487
+ routerContext?.tools?.find(
488
+ (tool) =>
489
+ (tool as { name?: string }).name ===
490
+ `${Constants.LC_TRANSFER_TO_}processor`
491
+ )
492
+ ).toBeDefined();
493
+ expect(
494
+ processorContext?.tools?.find(
495
+ (tool) =>
496
+ (tool as { name?: string }).name ===
497
+ `${Constants.LC_TRANSFER_TO_}reviewer`
498
+ )
499
+ ).toBeDefined();
500
+ });
501
+ });
502
+
503
+ describe('Edge Cases', () => {
504
+ it('should not modify messages when agent already uses thinking', async () => {
505
+ const agents: t.AgentInputs[] = [
506
+ {
507
+ agentId: 'agent_a',
508
+ provider: Providers.ANTHROPIC,
509
+ clientOptions: {
510
+ modelName: 'claude-3-7-sonnet-20250219',
511
+ apiKey: 'test-key',
512
+ thinking: {
513
+ type: 'enabled',
514
+ budget_tokens: 2000,
515
+ },
516
+ },
517
+ instructions: 'You are agent A with thinking',
518
+ maxContextTokens: 8000,
519
+ },
520
+ {
521
+ agentId: 'agent_b',
522
+ provider: Providers.ANTHROPIC,
523
+ clientOptions: {
524
+ modelName: 'claude-3-7-sonnet-20250219',
525
+ apiKey: 'test-key',
526
+ thinking: {
527
+ type: 'enabled',
528
+ budget_tokens: 2000,
529
+ },
530
+ },
531
+ instructions: 'You are agent B with thinking',
532
+ maxContextTokens: 8000,
533
+ },
534
+ ];
535
+
536
+ const edges: t.GraphEdge[] = [
537
+ {
538
+ from: 'agent_a',
539
+ to: 'agent_b',
540
+ edgeType: 'handoff',
541
+ },
542
+ ];
543
+
544
+ const run = await Run.create(createTestConfig(agents, edges));
545
+
546
+ run.Graph?.overrideTestModel(['Transferring', 'Received handoff'], 10, [
547
+ {
548
+ id: 'tool_call_1',
549
+ name: `${Constants.LC_TRANSFER_TO_}agent_b`,
550
+ args: {},
551
+ } as ToolCall,
552
+ ]);
553
+
554
+ const messages = [new HumanMessage('Test message')];
555
+
556
+ const config: Partial<RunnableConfig> & {
557
+ version: 'v1' | 'v2';
558
+ streamMode: string;
559
+ } = {
560
+ configurable: {
561
+ thread_id: 'test-both-thinking-thread',
562
+ },
563
+ streamMode: 'values',
564
+ version: 'v2' as const,
565
+ };
566
+
567
+ // Should work fine when both agents use thinking
568
+ await expect(
569
+ run.processStream({ messages }, config)
570
+ ).resolves.not.toThrow();
571
+ });
572
+
573
+ it('should handle empty conversation history', async () => {
574
+ const agents: t.AgentInputs[] = [
575
+ {
576
+ agentId: 'agent_a',
577
+ provider: Providers.OPENAI,
578
+ clientOptions: {
579
+ modelName: 'gpt-4o-mini',
580
+ apiKey: 'test-key',
581
+ },
582
+ instructions: 'You are agent A',
583
+ maxContextTokens: 8000,
584
+ },
585
+ {
586
+ agentId: 'agent_b',
587
+ provider: Providers.ANTHROPIC,
588
+ clientOptions: {
589
+ modelName: 'claude-3-7-sonnet-20250219',
590
+ apiKey: 'test-key',
591
+ thinking: {
592
+ type: 'enabled',
593
+ budget_tokens: 2000,
594
+ },
595
+ },
596
+ instructions: 'You are agent B',
597
+ maxContextTokens: 8000,
598
+ },
599
+ ];
600
+
601
+ const edges: t.GraphEdge[] = [
602
+ {
603
+ from: 'agent_a',
604
+ to: 'agent_b',
605
+ edgeType: 'handoff',
606
+ },
607
+ ];
608
+
609
+ const run = await Run.create(createTestConfig(agents, edges));
610
+
611
+ expect(run.Graph).toBeDefined();
612
+
613
+ // Just verify the graph was created correctly
614
+ const agentBContext = (run.Graph as StandardGraph).agentContexts.get(
615
+ 'agent_b'
616
+ );
617
+ expect(agentBContext).toBeDefined();
618
+ });
619
+ });
620
+ });
package/src/stream.ts CHANGED
@@ -194,6 +194,7 @@ export class ChatModelStreamHandler implements t.EventHandler {
194
194
  graph,
195
195
  stepKey,
196
196
  toolCallChunks: chunk.tool_call_chunks,
197
+ metadata,
197
198
  });
198
199
  }
199
200
 
@@ -203,12 +204,16 @@ export class ChatModelStreamHandler implements t.EventHandler {
203
204
 
204
205
  const message_id = getMessageId(stepKey, graph) ?? '';
205
206
  if (message_id) {
206
- await graph.dispatchRunStep(stepKey, {
207
- type: StepTypes.MESSAGE_CREATION,
208
- message_creation: {
209
- message_id,
207
+ await graph.dispatchRunStep(
208
+ stepKey,
209
+ {
210
+ type: StepTypes.MESSAGE_CREATION,
211
+ message_creation: {
212
+ message_id,
213
+ },
210
214
  },
211
- });
215
+ metadata
216
+ );
212
217
  }
213
218
 
214
219
  const stepId = graph.getStepIdByKey(stepKey);
@@ -267,12 +272,16 @@ hasToolCallChunks: ${hasToolCallChunks}
267
272
  agentContext.tokenTypeSwitch = 'content';
268
273
  const newStepKey = graph.getStepKey(metadata);
269
274
  const message_id = getMessageId(newStepKey, graph) ?? '';
270
- await graph.dispatchRunStep(newStepKey, {
271
- type: StepTypes.MESSAGE_CREATION,
272
- message_creation: {
273
- message_id,
275
+ await graph.dispatchRunStep(
276
+ newStepKey,
277
+ {
278
+ type: StepTypes.MESSAGE_CREATION,
279
+ message_creation: {
280
+ message_id,
281
+ },
274
282
  },
275
- });
283
+ metadata
284
+ );
276
285
 
277
286
  const newStepId = graph.getStepIdByKey(newStepKey);
278
287
  await graph.dispatchMessageDelta(newStepId, {