@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/dist/cjs/graphs/MultiAgentGraph.cjs +182 -8
- package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +9 -0
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/esm/graphs/MultiAgentGraph.mjs +183 -9
- package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +9 -0
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/types/graphs/MultiAgentGraph.d.ts +13 -0
- package/package.json +2 -1
- package/src/graphs/MultiAgentGraph.ts +224 -10
- package/src/scripts/multi-agent-conditional.ts +1 -0
- package/src/scripts/multi-agent-supervisor.ts +1 -0
- package/src/scripts/test-handoff-preamble.ts +275 -0
- package/src/tools/ToolNode.ts +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@librechat/agents",
|
|
3
|
-
"version": "3.0.
|
|
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
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
|
@@ -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
|
+
});
|
package/src/tools/ToolNode.ts
CHANGED
|
@@ -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 {
|