@librechat/agents 3.0.61 → 3.0.63

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@librechat/agents",
3
- "version": "3.0.61",
3
+ "version": "3.0.63",
4
4
  "main": "./dist/cjs/main.cjs",
5
5
  "module": "./dist/esm/main.mjs",
6
6
  "types": "./dist/types/index.d.ts",
@@ -75,6 +75,7 @@
75
75
  "multi-agent-sequence": "node -r dotenv/config --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/scripts/multi-agent-sequence.ts",
76
76
  "multi-agent-conditional": "node -r dotenv/config --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/scripts/multi-agent-conditional.ts",
77
77
  "multi-agent-supervisor": "node -r dotenv/config --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/scripts/multi-agent-supervisor.ts",
78
+ "test-handoff-preamble": "node -r dotenv/config --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/scripts/test-handoff-preamble.ts",
78
79
  "multi-agent-list-handoff": "node -r dotenv/config --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/scripts/test-multi-agent-list-handoff.ts",
79
80
  "test-parallel-agent-labeling": "node -r dotenv/config --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/scripts/test-parallel-agent-labeling.ts",
80
81
  "test-thinking-handoff": "node -r dotenv/config --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/scripts/test-thinking-handoff.ts",
@@ -2,6 +2,7 @@ import { z } from 'zod';
2
2
  import { tool } from '@langchain/core/tools';
3
3
  import { PromptTemplate } from '@langchain/core/prompts';
4
4
  import {
5
+ AIMessage,
5
6
  ToolMessage,
6
7
  HumanMessage,
7
8
  getBufferString,
@@ -15,12 +16,15 @@ import {
15
16
  getCurrentTaskInput,
16
17
  messagesStateReducer,
17
18
  } from '@langchain/langgraph';
19
+ import type { BaseMessage, AIMessageChunk } from '@langchain/core/messages';
18
20
  import type { ToolRunnableConfig } from '@langchain/core/tools';
19
- import type { BaseMessage } from '@langchain/core/messages';
20
21
  import type * as t from '@/types';
21
22
  import { StandardGraph } from './Graph';
22
23
  import { Constants } from '@/common';
23
24
 
25
+ /** Pattern to extract instructions from transfer ToolMessage content */
26
+ const HANDOFF_INSTRUCTIONS_PATTERN = /(?:Instructions?|Context):\s*(.+)/is;
27
+
24
28
  /**
25
29
  * MultiAgentGraph extends StandardGraph to support dynamic multi-agent workflows
26
30
  * with handoffs, fan-in/fan-out, and other composable patterns.
@@ -312,6 +316,10 @@ export class MultiAgentGraph extends StandardGraph {
312
316
  content,
313
317
  name: toolName,
314
318
  tool_call_id: toolCallId,
319
+ additional_kwargs: {
320
+ /** Store destination for programmatic access in handoff detection */
321
+ handoff_destination: destination,
322
+ },
315
323
  });
316
324
 
317
325
  return new Command({
@@ -407,6 +415,153 @@ export class MultiAgentGraph extends StandardGraph {
407
415
  return this.createAgentNode(agentId);
408
416
  }
409
417
 
418
+ /**
419
+ * Detects if the current agent is receiving a handoff and processes the messages accordingly.
420
+ * Returns filtered messages with the transfer tool call/message removed, plus any instructions
421
+ * extracted from the transfer to be injected as a HumanMessage preamble.
422
+ *
423
+ * Supports both single handoffs (last message is the transfer) and parallel handoffs
424
+ * (multiple transfer ToolMessages, need to find the one targeting this agent).
425
+ *
426
+ * @param messages - Current state messages
427
+ * @param agentId - The agent ID to check for handoff reception
428
+ * @returns Object with filtered messages and extracted instructions, or null if not a handoff
429
+ */
430
+ private processHandoffReception(
431
+ messages: BaseMessage[],
432
+ agentId: string
433
+ ): { filteredMessages: BaseMessage[]; instructions: string | null } | null {
434
+ if (messages.length === 0) return null;
435
+
436
+ /**
437
+ * Search for a transfer ToolMessage targeting this agent.
438
+ * For parallel handoffs, multiple transfer messages may exist - find ours.
439
+ * Search backwards from the end to find the most recent transfer to this agent.
440
+ */
441
+ let toolMessage: ToolMessage | null = null;
442
+ let toolMessageIndex = -1;
443
+
444
+ for (let i = messages.length - 1; i >= 0; i--) {
445
+ const msg = messages[i];
446
+ if (msg.getType() !== 'tool') continue;
447
+
448
+ const candidateMsg = msg as ToolMessage;
449
+ const toolName = candidateMsg.name;
450
+
451
+ if (typeof toolName !== 'string') continue;
452
+
453
+ /** Check for standard transfer pattern */
454
+ const isTransferMessage = toolName.startsWith(Constants.LC_TRANSFER_TO_);
455
+ const isConditionalTransfer = toolName === 'conditional_transfer';
456
+
457
+ if (!isTransferMessage && !isConditionalTransfer) continue;
458
+
459
+ /** Extract destination from tool name or additional_kwargs */
460
+ let destinationAgent: string | null = null;
461
+
462
+ if (isTransferMessage) {
463
+ destinationAgent = toolName.replace(Constants.LC_TRANSFER_TO_, '');
464
+ } else if (isConditionalTransfer) {
465
+ const handoffDest = candidateMsg.additional_kwargs.handoff_destination;
466
+ destinationAgent = typeof handoffDest === 'string' ? handoffDest : null;
467
+ }
468
+
469
+ /** Check if this transfer targets our agent */
470
+ if (destinationAgent === agentId) {
471
+ toolMessage = candidateMsg;
472
+ toolMessageIndex = i;
473
+ break;
474
+ }
475
+ }
476
+
477
+ /** No transfer targeting this agent found */
478
+ if (toolMessage === null || toolMessageIndex < 0) return null;
479
+
480
+ /** Extract instructions from the ToolMessage content */
481
+ const contentStr =
482
+ typeof toolMessage.content === 'string'
483
+ ? toolMessage.content
484
+ : JSON.stringify(toolMessage.content);
485
+
486
+ const instructionsMatch = contentStr.match(HANDOFF_INSTRUCTIONS_PATTERN);
487
+ const instructions = instructionsMatch?.[1]?.trim() ?? null;
488
+
489
+ /** Get the tool_call_id to find and filter the AI message's tool call */
490
+ const toolCallId = toolMessage.tool_call_id;
491
+
492
+ /**
493
+ * Collect all transfer tool_call_ids to filter out.
494
+ * For parallel handoffs, we filter ALL transfer messages (not just ours)
495
+ * to give the receiving agent a clean context without handoff noise.
496
+ */
497
+ const transferToolCallIds = new Set<string>([toolCallId]);
498
+ for (const msg of messages) {
499
+ if (msg.getType() !== 'tool') continue;
500
+ const tm = msg as ToolMessage;
501
+ const tName = tm.name;
502
+ if (typeof tName !== 'string') continue;
503
+ if (
504
+ tName.startsWith(Constants.LC_TRANSFER_TO_) ||
505
+ tName === 'conditional_transfer'
506
+ ) {
507
+ transferToolCallIds.add(tm.tool_call_id);
508
+ }
509
+ }
510
+
511
+ /** Filter out all transfer messages */
512
+ const filteredMessages: BaseMessage[] = [];
513
+
514
+ for (let i = 0; i < messages.length; i++) {
515
+ const msg = messages[i];
516
+ const msgType = msg.getType();
517
+
518
+ /** Skip transfer ToolMessages */
519
+ if (msgType === 'tool') {
520
+ const tm = msg as ToolMessage;
521
+ if (transferToolCallIds.has(tm.tool_call_id)) {
522
+ continue;
523
+ }
524
+ }
525
+
526
+ if (msgType === 'ai') {
527
+ /** Check if this AI message contains any transfer tool calls */
528
+ const aiMsg = msg as AIMessage | AIMessageChunk;
529
+ const toolCalls = aiMsg.tool_calls;
530
+
531
+ if (toolCalls && toolCalls.length > 0) {
532
+ /** Filter out all transfer tool calls */
533
+ const remainingToolCalls = toolCalls.filter(
534
+ (tc) => tc.id == null || !transferToolCallIds.has(tc.id)
535
+ );
536
+
537
+ const hasTransferCalls = remainingToolCalls.length < toolCalls.length;
538
+
539
+ if (hasTransferCalls) {
540
+ if (
541
+ remainingToolCalls.length > 0 ||
542
+ (typeof aiMsg.content === 'string' && aiMsg.content.trim())
543
+ ) {
544
+ /** Keep the message but without transfer tool calls */
545
+ const filteredAiMsg = new AIMessage({
546
+ content: aiMsg.content,
547
+ tool_calls: remainingToolCalls,
548
+ id: aiMsg.id,
549
+ });
550
+ filteredMessages.push(filteredAiMsg);
551
+ }
552
+ /** If no remaining content or tool calls, skip this message entirely */
553
+ continue;
554
+ }
555
+ }
556
+ }
557
+
558
+ /** Keep all other messages */
559
+ filteredMessages.push(msg);
560
+ }
561
+
562
+ return { filteredMessages, instructions };
563
+ }
564
+
410
565
  /**
411
566
  * Create the multi-agent workflow with dynamic handoffs
412
567
  */
@@ -474,26 +629,85 @@ export class MultiAgentGraph extends StandardGraph {
474
629
  /** Agent subgraph (includes agent + tools) */
475
630
  const agentSubgraph = this.createAgentSubgraph(agentId);
476
631
 
477
- /** Wrapper function that handles agentMessages channel and conditional routing */
632
+ /** Wrapper function that handles agentMessages channel, handoff reception, and conditional routing */
478
633
  const agentWrapper = async (
479
634
  state: t.MultiAgentGraphState
480
635
  ): Promise<t.MultiAgentGraphState | Command> => {
481
636
  let result: t.MultiAgentGraphState;
482
637
 
483
- if (state.agentMessages != null && state.agentMessages.length > 0) {
638
+ /**
639
+ * Check if this agent is receiving a handoff.
640
+ * If so, filter out the transfer messages and inject instructions as preamble.
641
+ * This prevents the receiving agent from seeing the transfer as "completed work"
642
+ * and prematurely producing an end token.
643
+ */
644
+ const handoffContext = this.processHandoffReception(
645
+ state.messages,
646
+ agentId
647
+ );
648
+
649
+ if (handoffContext !== null) {
650
+ const { filteredMessages, instructions } = handoffContext;
651
+
652
+ /** Build messages for the receiving agent */
653
+ let messagesForAgent = filteredMessages;
654
+
655
+ /** If there are instructions, inject them as a HumanMessage to ground the agent */
656
+ const hasInstructions = instructions !== null && instructions !== '';
657
+ if (hasInstructions) {
658
+ messagesForAgent = [
659
+ ...filteredMessages,
660
+ new HumanMessage(instructions),
661
+ ];
662
+ }
663
+
664
+ /** Update token map if we have a token counter */
665
+ const agentContext = this.agentContexts.get(agentId);
666
+ if (agentContext?.tokenCounter && hasInstructions) {
667
+ const freshTokenMap: Record<string, number> = {};
668
+ for (
669
+ let i = 0;
670
+ i < Math.min(filteredMessages.length, this.startIndex);
671
+ i++
672
+ ) {
673
+ const tokenCount = agentContext.indexTokenCountMap[i];
674
+ if (tokenCount !== undefined) {
675
+ freshTokenMap[i] = tokenCount;
676
+ }
677
+ }
678
+ /** Add tokens for the instructions message */
679
+ const instructionsMsg = new HumanMessage(instructions);
680
+ freshTokenMap[messagesForAgent.length - 1] =
681
+ agentContext.tokenCounter(instructionsMsg);
682
+ agentContext.updateTokenMapWithInstructions(freshTokenMap);
683
+ }
684
+
685
+ const transformedState: t.MultiAgentGraphState = {
686
+ ...state,
687
+ messages: messagesForAgent,
688
+ };
689
+ result = await agentSubgraph.invoke(transformedState);
690
+ result = {
691
+ ...result,
692
+ agentMessages: [],
693
+ };
694
+ } else if (
695
+ state.agentMessages != null &&
696
+ state.agentMessages.length > 0
697
+ ) {
484
698
  /**
485
699
  * When using agentMessages (excludeResults=true), we need to update
486
700
  * the token map to account for the new prompt message
487
701
  */
488
702
  const agentContext = this.agentContexts.get(agentId);
489
703
  if (agentContext && agentContext.tokenCounter) {
490
- // The agentMessages contains:
491
- // 1. Filtered messages (0 to startIndex) - already have token counts
492
- // 2. New prompt message - needs token counting
493
-
704
+ /** The agentMessages contains:
705
+ * 1. Filtered messages (0 to startIndex) - already have token counts
706
+ * 2. New prompt message - needs token counting
707
+ */
494
708
  const freshTokenMap: Record<string, number> = {};
495
709
 
496
- // Copy existing token counts for filtered messages (0 to startIndex)
710
+ /** Copy existing token counts for filtered messages (0 to startIndex) */
497
711
  for (let i = 0; i < this.startIndex; i++) {
498
712
  const tokenCount = agentContext.indexTokenCountMap[i];
499
713
  if (tokenCount !== undefined) {
@@ -501,7 +715,7 @@ export class MultiAgentGraph extends StandardGraph {
501
715
  }
502
716
  }
503
717
 
504
- // Calculate tokens only for the new prompt message (last message)
718
+ /** Calculate tokens only for the new prompt message (last message) */
505
719
  const promptMessageIndex = state.agentMessages.length - 1;
506
720
  if (promptMessageIndex >= this.startIndex) {
507
721
  const promptMessage = state.agentMessages[promptMessageIndex];
@@ -509,7 +723,7 @@ export class MultiAgentGraph extends StandardGraph {
509
723
  agentContext.tokenCounter(promptMessage);
510
724
  }
511
725
 
512
- // Update the agent's token map with instructions added
726
+ /** Update the agent's token map with instructions added */
513
727
  agentContext.updateTokenMapWithInstructions(freshTokenMap);
514
728
  }
515
729
 
@@ -210,6 +210,7 @@ async function testConditionalMultiAgent() {
210
210
  console.log(`\n\nExpert used: ${selectedExpert}`);
211
211
  console.log('Content parts:', contentParts.length);
212
212
  console.log('---');
213
+ console.dir(contentParts, { depth: null });
213
214
  }
214
215
  } catch (error) {
215
216
  console.error('Error in conditional multi-agent test:', error);
@@ -353,6 +353,7 @@ async function testSupervisorMultiAgent() {
353
353
  );
354
354
  console.log(` transfer_to_legal_advisor`);
355
355
  console.log('─'.repeat(60));
356
+ console.dir(contentParts, { depth: null });
356
357
  }
357
358
  await sleep(3000);
358
359
  } catch (error) {
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Test script for multi-turn handoff behavior.
3
+ *
4
+ * This tests the fix for the issue where receiving agents would see transfer messages
5
+ * and prematurely produce end tokens, thinking the work was already done.
6
+ *
7
+ * The fix:
8
+ * 1. Filters out transfer tool calls and ToolMessages from the receiving agent's context
9
+ * 2. Injects any passthrough instructions as a HumanMessage to ground the receiving agent
10
+ */
11
+ import { config } from 'dotenv';
12
+ config();
13
+
14
+ import { HumanMessage, BaseMessage } from '@langchain/core/messages';
15
+ import type { RunnableConfig } from '@langchain/core/runnables';
16
+ import type * as t from '@/types';
17
+ import { ChatModelStreamHandler, createContentAggregator } from '@/stream';
18
+ import { ToolEndHandler, ModelEndHandler } from '@/events';
19
+ import { getLLMConfig } from '@/utils/llmConfig';
20
+ import { GraphEvents, Providers } from '@/common';
21
+ import { Run } from '@/run';
22
+
23
+ const conversationHistory: BaseMessage[] = [];
24
+
25
+ /**
26
+ * Test multi-turn handoff between a coordinator and a specialist
27
+ */
28
+ async function testHandoffPreamble(): Promise<void> {
29
+ console.log('='.repeat(60));
30
+ console.log('Testing Multi-Turn Handoff with Preamble Injection');
31
+ console.log('='.repeat(60));
32
+ console.log('\nThis test verifies that:');
33
+ console.log('1. Transfer messages are filtered from receiving agent context');
34
+ console.log('2. Passthrough instructions are injected as a HumanMessage');
35
+ console.log('3. Multi-turn conversations work correctly after handoffs\n');
36
+
37
+ const { contentParts, aggregateContent } = createContentAggregator();
38
+
39
+ /** Track which agent is responding */
40
+ let currentAgent = '';
41
+
42
+ const customHandlers = {
43
+ [GraphEvents.TOOL_END]: new ToolEndHandler(),
44
+ [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
45
+ [GraphEvents.CHAT_MODEL_STREAM]: new ChatModelStreamHandler(),
46
+ [GraphEvents.ON_RUN_STEP]: {
47
+ handle: (
48
+ event: GraphEvents.ON_RUN_STEP,
49
+ data: t.StreamEventData
50
+ ): void => {
51
+ const runStep = data as t.RunStep;
52
+ if (runStep.agentId) {
53
+ currentAgent = runStep.agentId;
54
+ console.log(`\n[Agent: ${currentAgent}] Processing...`);
55
+ }
56
+ aggregateContent({ event, data: runStep });
57
+ },
58
+ },
59
+ [GraphEvents.ON_RUN_STEP_COMPLETED]: {
60
+ handle: (
61
+ event: GraphEvents.ON_RUN_STEP_COMPLETED,
62
+ data: t.StreamEventData
63
+ ): void => {
64
+ aggregateContent({
65
+ event,
66
+ data: data as unknown as { result: t.ToolEndEvent },
67
+ });
68
+ },
69
+ },
70
+ [GraphEvents.ON_MESSAGE_DELTA]: {
71
+ handle: (
72
+ event: GraphEvents.ON_MESSAGE_DELTA,
73
+ data: t.StreamEventData
74
+ ): void => {
75
+ console.log('====== ON_MESSAGE_DELTA ======');
76
+ console.dir(data, { depth: null });
77
+ aggregateContent({ event, data: data as t.MessageDeltaEvent });
78
+ },
79
+ },
80
+ [GraphEvents.TOOL_START]: {
81
+ handle: (
82
+ _event: string,
83
+ data: t.StreamEventData,
84
+ _metadata?: Record<string, unknown>
85
+ ): void => {
86
+ const toolData = data as { name?: string };
87
+ if (toolData?.name?.includes('transfer_to_')) {
88
+ const specialist = toolData.name.replace('lc_transfer_to_', '');
89
+ console.log(`\n🔀 Handing off to: ${specialist}`);
90
+ }
91
+ },
92
+ },
93
+ };
94
+
95
+ /**
96
+ * Create agents:
97
+ * - coordinator: Decides when to hand off to specialist
98
+ * - specialist: Handles specific tasks delegated by coordinator
99
+ */
100
+ const agents: t.AgentInputs[] = [
101
+ {
102
+ agentId: 'coordinator',
103
+ provider: Providers.OPENAI,
104
+ clientOptions: {
105
+ modelName: 'gpt-4.1-mini',
106
+ apiKey: process.env.OPENAI_API_KEY,
107
+ },
108
+ instructions: `You are a Task Coordinator. Your role is to:
109
+ 1. Understand user requests
110
+ 2. If the request involves technical analysis, use the transfer_to_specialist tool to hand off
111
+ 3. When handing off, provide clear instructions about what needs to be done
112
+
113
+ IMPORTANT: When using the handoff tool, include specific instructions for the specialist.`,
114
+ maxContextTokens: 8000,
115
+ },
116
+ {
117
+ agentId: 'specialist',
118
+ provider: Providers.OPENAI,
119
+ clientOptions: {
120
+ modelName: 'gpt-4.1-mini',
121
+ apiKey: process.env.OPENAI_API_KEY,
122
+ },
123
+ instructions: `You are a Technical Specialist. When you receive a request:
124
+ 1. Carefully read any instructions provided
125
+ 2. Provide a detailed technical response
126
+ 3. Do NOT just acknowledge - provide substantive help
127
+
128
+ IMPORTANT: You are the specialist - provide a complete, helpful response to the task.`,
129
+ maxContextTokens: 8000,
130
+ },
131
+ ];
132
+
133
+ /** Create handoff edge with passthrough instructions */
134
+ const edges: t.GraphEdge[] = [
135
+ {
136
+ from: 'coordinator',
137
+ to: 'specialist',
138
+ description: 'Transfer to technical specialist for analysis',
139
+ edgeType: 'handoff',
140
+ prompt: 'Specific instructions for the specialist about what to analyze',
141
+ promptKey: 'instructions',
142
+ },
143
+ ];
144
+
145
+ const runConfig: t.RunConfig = {
146
+ runId: `handoff-test-${Date.now()}`,
147
+ graphConfig: {
148
+ type: 'multi-agent',
149
+ agents,
150
+ edges,
151
+ },
152
+ customHandlers,
153
+ returnContent: true,
154
+ };
155
+
156
+ const run = await Run.create(runConfig);
157
+
158
+ const config: Partial<RunnableConfig> & {
159
+ version: 'v1' | 'v2';
160
+ streamMode: string;
161
+ } = {
162
+ configurable: {
163
+ thread_id: 'handoff-test-conversation-1',
164
+ },
165
+ streamMode: 'values',
166
+ version: 'v2' as const,
167
+ };
168
+
169
+ /** TURN 1: Initial request that triggers handoff */
170
+ console.log('\n' + '─'.repeat(60));
171
+ console.log('TURN 1: Initial request (should trigger handoff)');
172
+ console.log('─'.repeat(60));
173
+
174
+ const userMessage1 = `
175
+ Hi! Can you help me understand the time complexity of quicksort?
176
+ I need a technical explanation.
177
+ `;
178
+
179
+ conversationHistory.push(new HumanMessage(userMessage1));
180
+ console.log('\nUser:', userMessage1.trim());
181
+ console.log('\nResponse:');
182
+
183
+ let inputs = { messages: conversationHistory };
184
+ await run.processStream(inputs, config);
185
+ const messages1 = run.getRunMessages();
186
+ if (messages1) {
187
+ conversationHistory.push(...messages1);
188
+ }
189
+
190
+ console.log('\n');
191
+
192
+ /** TURN 2: Follow-up question to test multi-turn after handoff */
193
+ console.log('\n' + '─'.repeat(60));
194
+ console.log('TURN 2: Follow-up question (tests context after handoff)');
195
+ console.log('─'.repeat(60));
196
+
197
+ const userMessage2 = `
198
+ Thanks! Can you also explain the space complexity and when quicksort
199
+ might not be the best choice?
200
+ `;
201
+
202
+ conversationHistory.push(new HumanMessage(userMessage2));
203
+ console.log('\nUser:', userMessage2.trim());
204
+ console.log('\nResponse:');
205
+
206
+ inputs = { messages: conversationHistory };
207
+ await run.processStream(inputs, config);
208
+ const messages2 = run.getRunMessages();
209
+ if (messages2) {
210
+ conversationHistory.push(...messages2);
211
+ }
212
+
213
+ console.log('\n');
214
+
215
+ /** TURN 3: Another follow-up to verify sustained conversation */
216
+ console.log('\n' + '─'.repeat(60));
217
+ console.log('TURN 3: Third turn (tests sustained multi-turn)');
218
+ console.log('─'.repeat(60));
219
+
220
+ const userMessage3 = `
221
+ Great explanation! One more question - how does quicksort compare
222
+ to mergesort in practice?
223
+ `;
224
+
225
+ conversationHistory.push(new HumanMessage(userMessage3));
226
+ console.log('\nUser:', userMessage3.trim());
227
+ console.log('\nResponse:');
228
+
229
+ inputs = { messages: conversationHistory };
230
+ await run.processStream(inputs, config);
231
+ const messages3 = run.getRunMessages();
232
+ if (messages3) {
233
+ conversationHistory.push(...messages3);
234
+ }
235
+
236
+ /** Summary */
237
+ console.log('\n\n' + '='.repeat(60));
238
+ console.log('TEST SUMMARY');
239
+ console.log('='.repeat(60));
240
+ console.log('\nTotal messages in conversation:', conversationHistory.length);
241
+ console.log('\nMessage types:');
242
+
243
+ for (let i = 0; i < conversationHistory.length; i++) {
244
+ const msg = conversationHistory[i];
245
+ const type = msg.getType();
246
+ const preview =
247
+ typeof msg.content === 'string'
248
+ ? msg.content.slice(0, 50).replace(/\n/g, ' ')
249
+ : '[complex content]';
250
+ console.log(` ${i + 1}. [${type}] ${preview}...`);
251
+ }
252
+
253
+ console.log('\n✅ Test completed. Review the output above to verify:');
254
+ console.log(' - Specialist received and acted on instructions');
255
+ console.log(' - No premature end tokens after handoff');
256
+ console.log(' - Multi-turn conversation continued smoothly');
257
+ }
258
+
259
+ process.on('unhandledRejection', (reason, promise) => {
260
+ console.error('Unhandled Rejection at:', promise, 'reason:', reason);
261
+ console.log('\nConversation history at failure:');
262
+ console.dir(conversationHistory, { depth: null });
263
+ process.exit(1);
264
+ });
265
+
266
+ process.on('uncaughtException', (err) => {
267
+ console.error('Uncaught Exception:', err);
268
+ });
269
+
270
+ testHandoffPreamble().catch((err) => {
271
+ console.error('Test failed:', err);
272
+ console.log('\nConversation history at failure:');
273
+ console.dir(conversationHistory, { depth: null });
274
+ process.exit(1);
275
+ });
@@ -288,6 +288,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
288
288
  Array.isArray(output.goto) &&
289
289
  output.goto.every((send): send is Send => isSend(send))
290
290
  ) {
291
+ /** Aggregate Send-based commands */
291
292
  if (parentCommand) {
292
293
  (parentCommand.goto as Send[]).push(...(output.goto as Send[]));
293
294
  } else {
@@ -297,6 +298,14 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
297
298
  });
298
299
  }
299
300
  } else {
301
+ /**
302
+ * Non-Send Commands (including handoff Commands with string goto)
303
+ * are passed through as-is. For single handoffs, this works correctly.
304
+ *
305
+ * Note: Parallel handoffs (LLM calling multiple transfer tools simultaneously)
306
+ * are not yet fully supported. For parallel agent execution, use direct edges
307
+ * with edgeType: 'direct' instead of handoff edges.
308
+ */
300
309
  combinedOutputs.push(output);
301
310
  }
302
311
  } else {