@librechat/agents 3.0.60 → 3.0.62
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 +142 -8
- package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
- package/dist/cjs/stream.cjs +11 -6
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/esm/graphs/MultiAgentGraph.mjs +143 -9
- package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
- package/dist/esm/stream.mjs +11 -6
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/types/graphs/MultiAgentGraph.d.ts +10 -0
- package/dist/types/types/stream.d.ts +2 -11
- package/package.json +2 -1
- package/src/graphs/MultiAgentGraph.ts +183 -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/stream.ts +12 -6
- package/src/types/stream.ts +4 -12
|
@@ -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,112 @@ 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
|
+
* @param messages - Current state messages
|
|
424
|
+
* @param agentId - The agent ID to check for handoff reception
|
|
425
|
+
* @returns Object with filtered messages and extracted instructions, or null if not a handoff
|
|
426
|
+
*/
|
|
427
|
+
private processHandoffReception(
|
|
428
|
+
messages: BaseMessage[],
|
|
429
|
+
agentId: string
|
|
430
|
+
): { filteredMessages: BaseMessage[]; instructions: string | null } | null {
|
|
431
|
+
if (messages.length === 0) return null;
|
|
432
|
+
|
|
433
|
+
const lastMessage = messages[messages.length - 1];
|
|
434
|
+
|
|
435
|
+
/** Check if last message is a transfer ToolMessage targeting this agent */
|
|
436
|
+
if (lastMessage.getType() !== 'tool') return null;
|
|
437
|
+
|
|
438
|
+
const toolMessage = lastMessage as ToolMessage;
|
|
439
|
+
const toolName = toolMessage.name;
|
|
440
|
+
|
|
441
|
+
if (typeof toolName !== 'string') return null;
|
|
442
|
+
|
|
443
|
+
/** Check for standard transfer pattern */
|
|
444
|
+
const isTransferMessage = toolName.startsWith(Constants.LC_TRANSFER_TO_);
|
|
445
|
+
const isConditionalTransfer = toolName === 'conditional_transfer';
|
|
446
|
+
|
|
447
|
+
if (!isTransferMessage && !isConditionalTransfer) return null;
|
|
448
|
+
|
|
449
|
+
/** Extract destination from tool name or additional_kwargs */
|
|
450
|
+
let destinationAgent: string | null = null;
|
|
451
|
+
|
|
452
|
+
if (isTransferMessage) {
|
|
453
|
+
destinationAgent = toolName.replace(Constants.LC_TRANSFER_TO_, '');
|
|
454
|
+
} else if (isConditionalTransfer) {
|
|
455
|
+
/** For conditional transfers, read destination from additional_kwargs */
|
|
456
|
+
const handoffDest = toolMessage.additional_kwargs.handoff_destination;
|
|
457
|
+
destinationAgent = typeof handoffDest === 'string' ? handoffDest : null;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Verify this agent is the intended destination */
|
|
461
|
+
if (destinationAgent !== agentId) return null;
|
|
462
|
+
|
|
463
|
+
/** Extract instructions from the ToolMessage content */
|
|
464
|
+
const contentStr =
|
|
465
|
+
typeof toolMessage.content === 'string'
|
|
466
|
+
? toolMessage.content
|
|
467
|
+
: JSON.stringify(toolMessage.content);
|
|
468
|
+
|
|
469
|
+
const instructionsMatch = contentStr.match(HANDOFF_INSTRUCTIONS_PATTERN);
|
|
470
|
+
const instructions = instructionsMatch?.[1]?.trim() ?? null;
|
|
471
|
+
|
|
472
|
+
/** Get the tool_call_id to find and filter the AI message's tool call */
|
|
473
|
+
const toolCallId = toolMessage.tool_call_id;
|
|
474
|
+
|
|
475
|
+
/** Filter out the transfer messages */
|
|
476
|
+
const filteredMessages: BaseMessage[] = [];
|
|
477
|
+
|
|
478
|
+
for (let i = 0; i < messages.length - 1; i++) {
|
|
479
|
+
/** Exclude the last message (ToolMessage) by iterating to length - 1 */
|
|
480
|
+
const msg = messages[i];
|
|
481
|
+
const msgType = msg.getType();
|
|
482
|
+
|
|
483
|
+
if (msgType === 'ai') {
|
|
484
|
+
/** Check if this AI message contains the transfer tool call */
|
|
485
|
+
const aiMsg = msg as AIMessage | AIMessageChunk;
|
|
486
|
+
const toolCalls = aiMsg.tool_calls;
|
|
487
|
+
|
|
488
|
+
if (toolCalls && toolCalls.length > 0) {
|
|
489
|
+
const transferCallIndex = toolCalls.findIndex(
|
|
490
|
+
(tc) => tc.id === toolCallId
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
if (transferCallIndex >= 0) {
|
|
494
|
+
/** This AI message has the transfer tool call - filter it out */
|
|
495
|
+
const remainingToolCalls = toolCalls.filter(
|
|
496
|
+
(tc) => tc.id !== toolCallId
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
if (
|
|
500
|
+
remainingToolCalls.length > 0 ||
|
|
501
|
+
(typeof aiMsg.content === 'string' && aiMsg.content.trim())
|
|
502
|
+
) {
|
|
503
|
+
/** Keep the message but without the transfer tool call */
|
|
504
|
+
const filteredAiMsg = new AIMessage({
|
|
505
|
+
content: aiMsg.content,
|
|
506
|
+
tool_calls: remainingToolCalls,
|
|
507
|
+
id: aiMsg.id,
|
|
508
|
+
});
|
|
509
|
+
filteredMessages.push(filteredAiMsg);
|
|
510
|
+
}
|
|
511
|
+
/** If no remaining content or tool calls, skip this message entirely */
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Keep all other messages */
|
|
518
|
+
filteredMessages.push(msg);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return { filteredMessages, instructions };
|
|
522
|
+
}
|
|
523
|
+
|
|
410
524
|
/**
|
|
411
525
|
* Create the multi-agent workflow with dynamic handoffs
|
|
412
526
|
*/
|
|
@@ -474,26 +588,85 @@ export class MultiAgentGraph extends StandardGraph {
|
|
|
474
588
|
/** Agent subgraph (includes agent + tools) */
|
|
475
589
|
const agentSubgraph = this.createAgentSubgraph(agentId);
|
|
476
590
|
|
|
477
|
-
/** Wrapper function that handles agentMessages channel and conditional routing */
|
|
591
|
+
/** Wrapper function that handles agentMessages channel, handoff reception, and conditional routing */
|
|
478
592
|
const agentWrapper = async (
|
|
479
593
|
state: t.MultiAgentGraphState
|
|
480
594
|
): Promise<t.MultiAgentGraphState | Command> => {
|
|
481
595
|
let result: t.MultiAgentGraphState;
|
|
482
596
|
|
|
483
|
-
|
|
597
|
+
/**
|
|
598
|
+
* Check if this agent is receiving a handoff.
|
|
599
|
+
* If so, filter out the transfer messages and inject instructions as preamble.
|
|
600
|
+
* This prevents the receiving agent from seeing the transfer as "completed work"
|
|
601
|
+
* and prematurely producing an end token.
|
|
602
|
+
*/
|
|
603
|
+
const handoffContext = this.processHandoffReception(
|
|
604
|
+
state.messages,
|
|
605
|
+
agentId
|
|
606
|
+
);
|
|
607
|
+
|
|
608
|
+
if (handoffContext !== null) {
|
|
609
|
+
const { filteredMessages, instructions } = handoffContext;
|
|
610
|
+
|
|
611
|
+
/** Build messages for the receiving agent */
|
|
612
|
+
let messagesForAgent = filteredMessages;
|
|
613
|
+
|
|
614
|
+
/** If there are instructions, inject them as a HumanMessage to ground the agent */
|
|
615
|
+
const hasInstructions = instructions !== null && instructions !== '';
|
|
616
|
+
if (hasInstructions) {
|
|
617
|
+
messagesForAgent = [
|
|
618
|
+
...filteredMessages,
|
|
619
|
+
new HumanMessage(instructions),
|
|
620
|
+
];
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** Update token map if we have a token counter */
|
|
624
|
+
const agentContext = this.agentContexts.get(agentId);
|
|
625
|
+
if (agentContext?.tokenCounter && hasInstructions) {
|
|
626
|
+
const freshTokenMap: Record<string, number> = {};
|
|
627
|
+
for (
|
|
628
|
+
let i = 0;
|
|
629
|
+
i < Math.min(filteredMessages.length, this.startIndex);
|
|
630
|
+
i++
|
|
631
|
+
) {
|
|
632
|
+
const tokenCount = agentContext.indexTokenCountMap[i];
|
|
633
|
+
if (tokenCount !== undefined) {
|
|
634
|
+
freshTokenMap[i] = tokenCount;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
/** Add tokens for the instructions message */
|
|
638
|
+
const instructionsMsg = new HumanMessage(instructions);
|
|
639
|
+
freshTokenMap[messagesForAgent.length - 1] =
|
|
640
|
+
agentContext.tokenCounter(instructionsMsg);
|
|
641
|
+
agentContext.updateTokenMapWithInstructions(freshTokenMap);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const transformedState: t.MultiAgentGraphState = {
|
|
645
|
+
...state,
|
|
646
|
+
messages: messagesForAgent,
|
|
647
|
+
};
|
|
648
|
+
result = await agentSubgraph.invoke(transformedState);
|
|
649
|
+
result = {
|
|
650
|
+
...result,
|
|
651
|
+
agentMessages: [],
|
|
652
|
+
};
|
|
653
|
+
} else if (
|
|
654
|
+
state.agentMessages != null &&
|
|
655
|
+
state.agentMessages.length > 0
|
|
656
|
+
) {
|
|
484
657
|
/**
|
|
485
658
|
* When using agentMessages (excludeResults=true), we need to update
|
|
486
659
|
* the token map to account for the new prompt message
|
|
487
660
|
*/
|
|
488
661
|
const agentContext = this.agentContexts.get(agentId);
|
|
489
662
|
if (agentContext && agentContext.tokenCounter) {
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
663
|
+
/** The agentMessages contains:
|
|
664
|
+
* 1. Filtered messages (0 to startIndex) - already have token counts
|
|
665
|
+
* 2. New prompt message - needs token counting
|
|
666
|
+
*/
|
|
494
667
|
const freshTokenMap: Record<string, number> = {};
|
|
495
668
|
|
|
496
|
-
|
|
669
|
+
/** Copy existing token counts for filtered messages (0 to startIndex) */
|
|
497
670
|
for (let i = 0; i < this.startIndex; i++) {
|
|
498
671
|
const tokenCount = agentContext.indexTokenCountMap[i];
|
|
499
672
|
if (tokenCount !== undefined) {
|
|
@@ -501,7 +674,7 @@ export class MultiAgentGraph extends StandardGraph {
|
|
|
501
674
|
}
|
|
502
675
|
}
|
|
503
676
|
|
|
504
|
-
|
|
677
|
+
/** Calculate tokens only for the new prompt message (last message) */
|
|
505
678
|
const promptMessageIndex = state.agentMessages.length - 1;
|
|
506
679
|
if (promptMessageIndex >= this.startIndex) {
|
|
507
680
|
const promptMessage = state.agentMessages[promptMessageIndex];
|
|
@@ -509,7 +682,7 @@ export class MultiAgentGraph extends StandardGraph {
|
|
|
509
682
|
agentContext.tokenCounter(promptMessage);
|
|
510
683
|
}
|
|
511
684
|
|
|
512
|
-
|
|
685
|
+
/** Update the agent's token map with instructions added */
|
|
513
686
|
agentContext.updateTokenMapWithInstructions(freshTokenMap);
|
|
514
687
|
}
|
|
515
688
|
|
|
@@ -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/stream.ts
CHANGED
|
@@ -583,6 +583,17 @@ export function createContentAggregator(): t.ContentAggregatorResult {
|
|
|
583
583
|
tool_call: newToolCall,
|
|
584
584
|
};
|
|
585
585
|
}
|
|
586
|
+
|
|
587
|
+
// Apply agentId (for MultiAgentGraph) and groupId (for parallel execution) to content parts
|
|
588
|
+
// - agentId present → MultiAgentGraph (show agent labels)
|
|
589
|
+
// - groupId present → parallel execution (render columns)
|
|
590
|
+
const meta = contentMetaMap.get(index);
|
|
591
|
+
if (meta?.agentId != null) {
|
|
592
|
+
(contentParts[index] as t.MessageContentComplex).agentId = meta.agentId;
|
|
593
|
+
}
|
|
594
|
+
if (meta?.groupId != null) {
|
|
595
|
+
(contentParts[index] as t.MessageContentComplex).groupId = meta.groupId;
|
|
596
|
+
}
|
|
586
597
|
};
|
|
587
598
|
|
|
588
599
|
const aggregateContent = ({
|
|
@@ -727,10 +738,5 @@ export function createContentAggregator(): t.ContentAggregatorResult {
|
|
|
727
738
|
}
|
|
728
739
|
};
|
|
729
740
|
|
|
730
|
-
return {
|
|
731
|
-
contentParts,
|
|
732
|
-
aggregateContent,
|
|
733
|
-
stepMap,
|
|
734
|
-
contentMetadataMap: contentMetaMap,
|
|
735
|
-
};
|
|
741
|
+
return { contentParts, aggregateContent, stepMap };
|
|
736
742
|
}
|
package/src/types/stream.ts
CHANGED
|
@@ -343,6 +343,10 @@ export type MessageContentComplex = (
|
|
|
343
343
|
})
|
|
344
344
|
) & {
|
|
345
345
|
tool_call_ids?: string[];
|
|
346
|
+
// Optional agentId for parallel execution attribution
|
|
347
|
+
agentId?: string;
|
|
348
|
+
// Optional groupId for parallel group attribution
|
|
349
|
+
groupId?: number;
|
|
346
350
|
};
|
|
347
351
|
|
|
348
352
|
export interface TMessage {
|
|
@@ -406,20 +410,8 @@ export type ContentAggregator = ({
|
|
|
406
410
|
result: ToolEndEvent;
|
|
407
411
|
};
|
|
408
412
|
}) => void;
|
|
409
|
-
/**
|
|
410
|
-
* Metadata for content parts in multi-agent runs.
|
|
411
|
-
* - agentId: present for all MultiAgentGraph runs (enables agent labels in UI)
|
|
412
|
-
* - groupId: present only for parallel execution (enables column rendering)
|
|
413
|
-
*/
|
|
414
|
-
export type ContentMetadata = {
|
|
415
|
-
agentId?: string;
|
|
416
|
-
groupId?: number;
|
|
417
|
-
};
|
|
418
|
-
|
|
419
413
|
export type ContentAggregatorResult = {
|
|
420
414
|
stepMap: Map<string, RunStep | undefined>;
|
|
421
415
|
contentParts: Array<MessageContentComplex | undefined>;
|
|
422
|
-
/** Map of content index to metadata (agentId, groupId). Only populated for MultiAgentGraph runs. */
|
|
423
|
-
contentMetadataMap: Map<number, ContentMetadata>;
|
|
424
416
|
aggregateContent: ContentAggregator;
|
|
425
417
|
};
|