@librechat/agents 3.1.40 → 3.1.42

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,427 @@
1
+ import { config } from 'dotenv';
2
+ config();
3
+
4
+ import { HumanMessage, BaseMessage } from '@langchain/core/messages';
5
+ import type { RunnableConfig } from '@langchain/core/runnables';
6
+ import type * as t from '@/types';
7
+ import { ChatModelStreamHandler, createContentAggregator } from '@/stream';
8
+ import { ToolEndHandler, ModelEndHandler } from '@/events';
9
+ import { GraphEvents, Providers } from '@/common';
10
+ import { Run } from '@/run';
11
+
12
+ /**
13
+ * Test LLM steering quality after handoff with system prompt instructions.
14
+ *
15
+ * Validates that the receiving agent clearly understands:
16
+ * 1. WHO it is (its role/identity)
17
+ * 2. WHAT the task is (instructions from the handoff)
18
+ * 3. WHO transferred control (source agent context)
19
+ *
20
+ * Uses specific, verifiable instructions so we can check the output.
21
+ */
22
+ async function testHandoffSteering(): Promise<void> {
23
+ console.log('='.repeat(60));
24
+ console.log('Test: Handoff Steering Quality (System Prompt Instructions)');
25
+ console.log('='.repeat(60));
26
+
27
+ const { contentParts, aggregateContent } = createContentAggregator();
28
+
29
+ let currentAgent = '';
30
+ const agentResponses: Record<string, string> = {};
31
+
32
+ const customHandlers = {
33
+ [GraphEvents.TOOL_END]: new ToolEndHandler(),
34
+ [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
35
+ [GraphEvents.CHAT_MODEL_STREAM]: new ChatModelStreamHandler(),
36
+ [GraphEvents.ON_RUN_STEP]: {
37
+ handle: (
38
+ event: GraphEvents.ON_RUN_STEP,
39
+ data: t.StreamEventData
40
+ ): void => {
41
+ const runStep = data as t.RunStep;
42
+ if (runStep.agentId) {
43
+ currentAgent = runStep.agentId;
44
+ console.log(`\n[Agent: ${currentAgent}] Processing...`);
45
+ }
46
+ aggregateContent({ event, data: runStep });
47
+ },
48
+ },
49
+ [GraphEvents.ON_RUN_STEP_COMPLETED]: {
50
+ handle: (
51
+ event: GraphEvents.ON_RUN_STEP_COMPLETED,
52
+ data: t.StreamEventData
53
+ ): void => {
54
+ aggregateContent({
55
+ event,
56
+ data: data as unknown as { result: t.ToolEndEvent },
57
+ });
58
+ },
59
+ },
60
+ [GraphEvents.ON_MESSAGE_DELTA]: {
61
+ handle: (
62
+ event: GraphEvents.ON_MESSAGE_DELTA,
63
+ data: t.StreamEventData
64
+ ): void => {
65
+ aggregateContent({ event, data: data as t.MessageDeltaEvent });
66
+ },
67
+ },
68
+ [GraphEvents.TOOL_START]: {
69
+ handle: (
70
+ _event: string,
71
+ data: t.StreamEventData,
72
+ _metadata?: Record<string, unknown>
73
+ ): void => {
74
+ const toolData = data as { name?: string };
75
+ if (toolData?.name?.includes('transfer_to_')) {
76
+ const specialist = toolData.name.replace('lc_transfer_to_', '');
77
+ console.log(`\n >> Handoff to: ${specialist}`);
78
+ }
79
+ },
80
+ },
81
+ };
82
+
83
+ /**
84
+ * Test 1: Basic handoff with specific task instructions
85
+ * The specialist should clearly follow the coordinator's instructions.
86
+ */
87
+ async function test1_basicInstructions(): Promise<void> {
88
+ console.log('\n' + '-'.repeat(60));
89
+ console.log('TEST 1: Basic handoff with specific task instructions');
90
+ console.log('-'.repeat(60));
91
+
92
+ const agents: t.AgentInputs[] = [
93
+ {
94
+ agentId: 'coordinator',
95
+ provider: Providers.OPENAI,
96
+ clientOptions: {
97
+ modelName: 'gpt-4.1-mini',
98
+ apiKey: process.env.OPENAI_API_KEY,
99
+ },
100
+ instructions: `You are a Task Coordinator. When a user makes a request:
101
+ 1. Analyze what they need
102
+ 2. Transfer to the specialist with SPECIFIC instructions about what to do
103
+
104
+ IMPORTANT: Always use the transfer tool. Do not try to do the work yourself.`,
105
+ maxContextTokens: 8000,
106
+ },
107
+ {
108
+ agentId: 'specialist',
109
+ provider: Providers.OPENAI,
110
+ clientOptions: {
111
+ modelName: 'gpt-4.1-mini',
112
+ apiKey: process.env.OPENAI_API_KEY,
113
+ },
114
+ instructions: `You are a Technical Specialist. You provide detailed technical responses.
115
+ When you receive a task, execute it thoroughly. Always identify yourself as the Technical Specialist in your response.`,
116
+ maxContextTokens: 8000,
117
+ },
118
+ ];
119
+
120
+ const edges: t.GraphEdge[] = [
121
+ {
122
+ from: 'coordinator',
123
+ to: 'specialist',
124
+ edgeType: 'handoff',
125
+ description: 'Transfer to specialist for detailed work',
126
+ prompt:
127
+ 'Provide specific instructions for the specialist about what to analyze or create',
128
+ promptKey: 'instructions',
129
+ },
130
+ ];
131
+
132
+ const run = await Run.create({
133
+ runId: `steering-test1-${Date.now()}`,
134
+ graphConfig: { type: 'multi-agent', agents, edges },
135
+ customHandlers,
136
+ returnContent: true,
137
+ });
138
+
139
+ const streamConfig: Partial<RunnableConfig> & {
140
+ version: 'v1' | 'v2';
141
+ streamMode: string;
142
+ } = {
143
+ configurable: { thread_id: 'steering-test1' },
144
+ streamMode: 'values',
145
+ version: 'v2' as const,
146
+ };
147
+
148
+ const query =
149
+ 'Explain the difference between TCP and UDP. I need exactly 3 bullet points for each protocol.';
150
+ console.log(`\nQuery: "${query}"\n`);
151
+
152
+ const messages = [new HumanMessage(query)];
153
+ await run.processStream({ messages }, streamConfig);
154
+ const finalMessages = run.getRunMessages();
155
+
156
+ console.log('\n--- Specialist Response ---');
157
+ if (finalMessages) {
158
+ for (const msg of finalMessages) {
159
+ if (msg.getType() === 'ai' && typeof msg.content === 'string') {
160
+ console.log(msg.content);
161
+ agentResponses['test1'] = msg.content;
162
+ }
163
+ }
164
+ }
165
+
166
+ // Check steering quality
167
+ const response = agentResponses['test1'] || '';
168
+ const mentionsSpecialist =
169
+ response.toLowerCase().includes('specialist') ||
170
+ response.toLowerCase().includes('technical');
171
+ const hasBulletPoints =
172
+ (response.match(/[-•*]\s/g) || []).length >= 4 ||
173
+ (response.match(/\d\./g) || []).length >= 4;
174
+ const mentionsTCP = response.toLowerCase().includes('tcp');
175
+ const mentionsUDP = response.toLowerCase().includes('udp');
176
+
177
+ console.log('\n--- Steering Checks ---');
178
+ console.log(
179
+ ` Identifies as specialist: ${mentionsSpecialist ? 'YES' : 'NO'}`
180
+ );
181
+ console.log(` Has bullet points: ${hasBulletPoints ? 'YES' : 'NO'}`);
182
+ console.log(` Covers TCP: ${mentionsTCP ? 'YES' : 'NO'}`);
183
+ console.log(` Covers UDP: ${mentionsUDP ? 'YES' : 'NO'}`);
184
+ }
185
+
186
+ /**
187
+ * Test 2: Handoff with very specific formatting instructions
188
+ * Tests whether the receiving agent follows precise instructions from the handoff.
189
+ */
190
+ async function test2_preciseFormatting(): Promise<void> {
191
+ console.log('\n' + '-'.repeat(60));
192
+ console.log('TEST 2: Handoff with precise formatting instructions');
193
+ console.log('-'.repeat(60));
194
+
195
+ const agents: t.AgentInputs[] = [
196
+ {
197
+ agentId: 'manager',
198
+ provider: Providers.OPENAI,
199
+ clientOptions: {
200
+ modelName: 'gpt-4.1-mini',
201
+ apiKey: process.env.OPENAI_API_KEY,
202
+ },
203
+ instructions: `You are a Project Manager. When a user asks about a topic:
204
+ 1. Transfer to the writer with VERY SPECIFIC formatting instructions
205
+ 2. Tell the writer to start their response with "REPORT:" and end with "END REPORT"
206
+ 3. Tell the writer to use exactly 2 paragraphs
207
+
208
+ CRITICAL: Always transfer to the writer. Do NOT write the report yourself.`,
209
+ maxContextTokens: 8000,
210
+ },
211
+ {
212
+ agentId: 'writer',
213
+ provider: Providers.OPENAI,
214
+ clientOptions: {
215
+ modelName: 'gpt-4.1-mini',
216
+ apiKey: process.env.OPENAI_API_KEY,
217
+ },
218
+ instructions: `You are a Report Writer. Follow any formatting instructions you receive precisely.
219
+ You must follow the exact format requested.`,
220
+ maxContextTokens: 8000,
221
+ },
222
+ ];
223
+
224
+ const edges: t.GraphEdge[] = [
225
+ {
226
+ from: 'manager',
227
+ to: 'writer',
228
+ edgeType: 'handoff',
229
+ description: 'Transfer to writer for report creation',
230
+ prompt:
231
+ 'Provide specific formatting and content instructions for the writer',
232
+ promptKey: 'instructions',
233
+ },
234
+ ];
235
+
236
+ const run = await Run.create({
237
+ runId: `steering-test2-${Date.now()}`,
238
+ graphConfig: { type: 'multi-agent', agents, edges },
239
+ customHandlers,
240
+ returnContent: true,
241
+ });
242
+
243
+ const streamConfig: Partial<RunnableConfig> & {
244
+ version: 'v1' | 'v2';
245
+ streamMode: string;
246
+ } = {
247
+ configurable: { thread_id: 'steering-test2' },
248
+ streamMode: 'values',
249
+ version: 'v2' as const,
250
+ };
251
+
252
+ const query = 'Write a brief report about cloud computing benefits.';
253
+ console.log(`\nQuery: "${query}"\n`);
254
+
255
+ const messages = [new HumanMessage(query)];
256
+ await run.processStream({ messages }, streamConfig);
257
+ const finalMessages = run.getRunMessages();
258
+
259
+ console.log('\n--- Writer Response ---');
260
+ if (finalMessages) {
261
+ for (const msg of finalMessages) {
262
+ if (msg.getType() === 'ai' && typeof msg.content === 'string') {
263
+ console.log(msg.content);
264
+ agentResponses['test2'] = msg.content;
265
+ }
266
+ }
267
+ }
268
+
269
+ // Check if the writer followed the manager's formatting instructions
270
+ const response = agentResponses['test2'] || '';
271
+ const startsWithReport = response.trimStart().startsWith('REPORT:');
272
+ const endsWithEndReport = response.trimEnd().endsWith('END REPORT');
273
+ const mentionsCloud = response.toLowerCase().includes('cloud');
274
+
275
+ console.log('\n--- Steering Checks ---');
276
+ console.log(` Starts with "REPORT:": ${startsWithReport ? 'YES' : 'NO'}`);
277
+ console.log(
278
+ ` Ends with "END REPORT": ${endsWithEndReport ? 'YES' : 'NO'}`
279
+ );
280
+ console.log(` Covers cloud computing: ${mentionsCloud ? 'YES' : 'NO'}`);
281
+ }
282
+
283
+ /**
284
+ * Test 3: Multi-turn after handoff
285
+ * Tests that identity and context persist across turns.
286
+ */
287
+ async function test3_multiTurn(): Promise<void> {
288
+ console.log('\n' + '-'.repeat(60));
289
+ console.log('TEST 3: Multi-turn conversation after handoff');
290
+ console.log('-'.repeat(60));
291
+
292
+ const agents: t.AgentInputs[] = [
293
+ {
294
+ agentId: 'router',
295
+ provider: Providers.OPENAI,
296
+ clientOptions: {
297
+ modelName: 'gpt-4.1-mini',
298
+ apiKey: process.env.OPENAI_API_KEY,
299
+ },
300
+ instructions: `You are a Router. Transfer all requests to the chef.
301
+ When transferring, tell the chef to respond ONLY about Italian cuisine.
302
+ CRITICAL: Always transfer. Never answer directly.`,
303
+ maxContextTokens: 8000,
304
+ },
305
+ {
306
+ agentId: 'chef',
307
+ provider: Providers.OPENAI,
308
+ clientOptions: {
309
+ modelName: 'gpt-4.1-mini',
310
+ apiKey: process.env.OPENAI_API_KEY,
311
+ },
312
+ instructions: `You are Chef Marco, an Italian cuisine expert.
313
+ Always introduce yourself as Chef Marco. Only discuss Italian food.
314
+ If asked about non-Italian food, politely redirect to Italian alternatives.`,
315
+ maxContextTokens: 8000,
316
+ },
317
+ ];
318
+
319
+ const edges: t.GraphEdge[] = [
320
+ {
321
+ from: 'router',
322
+ to: 'chef',
323
+ edgeType: 'handoff',
324
+ description: 'Transfer to chef',
325
+ prompt: 'Instructions for the chef about how to respond',
326
+ promptKey: 'instructions',
327
+ },
328
+ ];
329
+
330
+ const run = await Run.create({
331
+ runId: `steering-test3-${Date.now()}`,
332
+ graphConfig: { type: 'multi-agent', agents, edges },
333
+ customHandlers,
334
+ returnContent: true,
335
+ });
336
+
337
+ const streamConfig: Partial<RunnableConfig> & {
338
+ version: 'v1' | 'v2';
339
+ streamMode: string;
340
+ } = {
341
+ configurable: { thread_id: 'steering-test3' },
342
+ streamMode: 'values',
343
+ version: 'v2' as const,
344
+ };
345
+
346
+ const conversationHistory: BaseMessage[] = [];
347
+
348
+ // Turn 1
349
+ const query1 = 'What is a good pasta recipe?';
350
+ console.log(`\nTurn 1: "${query1}"\n`);
351
+ conversationHistory.push(new HumanMessage(query1));
352
+ await run.processStream({ messages: conversationHistory }, streamConfig);
353
+ const turn1Messages = run.getRunMessages();
354
+ if (turn1Messages) {
355
+ conversationHistory.push(...turn1Messages);
356
+ for (const msg of turn1Messages) {
357
+ if (msg.getType() === 'ai' && typeof msg.content === 'string') {
358
+ console.log(msg.content.substring(0, 300) + '...');
359
+ agentResponses['test3_turn1'] = msg.content;
360
+ }
361
+ }
362
+ }
363
+
364
+ // Turn 2 - follow up
365
+ const query2 = 'What about sushi instead?';
366
+ console.log(`\nTurn 2: "${query2}"\n`);
367
+ conversationHistory.push(new HumanMessage(query2));
368
+ await run.processStream({ messages: conversationHistory }, streamConfig);
369
+ const turn2Messages = run.getRunMessages();
370
+ if (turn2Messages) {
371
+ conversationHistory.push(...turn2Messages);
372
+ for (const msg of turn2Messages) {
373
+ if (msg.getType() === 'ai' && typeof msg.content === 'string') {
374
+ console.log(msg.content.substring(0, 300) + '...');
375
+ agentResponses['test3_turn2'] = msg.content;
376
+ }
377
+ }
378
+ }
379
+
380
+ const response1 = agentResponses['test3_turn1'] || '';
381
+ const response2 = agentResponses['test3_turn2'] || '';
382
+ const t1Identity =
383
+ response1.toLowerCase().includes('marco') ||
384
+ response1.toLowerCase().includes('chef');
385
+ const t1Italian =
386
+ response1.toLowerCase().includes('italian') ||
387
+ response1.toLowerCase().includes('pasta');
388
+ const t2Redirects =
389
+ response2.toLowerCase().includes('italian') ||
390
+ response2.toLowerCase().includes('instead');
391
+
392
+ console.log('\n--- Steering Checks ---');
393
+ console.log(` Turn 1 - Chef identity: ${t1Identity ? 'YES' : 'NO'}`);
394
+ console.log(` Turn 1 - Italian focus: ${t1Italian ? 'YES' : 'NO'}`);
395
+ console.log(
396
+ ` Turn 2 - Redirects to Italian: ${t2Redirects ? 'YES' : 'NO'}`
397
+ );
398
+ }
399
+
400
+ try {
401
+ await test1_basicInstructions();
402
+ await test2_preciseFormatting();
403
+ await test3_multiTurn();
404
+
405
+ console.log('\n\n' + '='.repeat(60));
406
+ console.log('ALL TESTS COMPLETE');
407
+ console.log('='.repeat(60));
408
+ console.log('\nReview the steering checks above.');
409
+ console.log(
410
+ 'If the receiving agents consistently follow instructions and maintain identity,'
411
+ );
412
+ console.log('the system prompt injection approach is working correctly.');
413
+ } catch (error) {
414
+ console.error('\nTest failed:', error);
415
+ process.exit(1);
416
+ }
417
+ }
418
+
419
+ process.on('unhandledRejection', (reason) => {
420
+ console.error('Unhandled Rejection:', reason);
421
+ process.exit(1);
422
+ });
423
+
424
+ testHandoffSteering().catch((err) => {
425
+ console.error('Test failed:', err);
426
+ process.exit(1);
427
+ });
@@ -0,0 +1,276 @@
1
+ import { config } from 'dotenv';
2
+ config();
3
+
4
+ import { z } from 'zod';
5
+ import { tool } from '@langchain/core/tools';
6
+ import { HumanMessage, BaseMessage } from '@langchain/core/messages';
7
+ import type { RunnableConfig } from '@langchain/core/runnables';
8
+ import type * as t from '@/types';
9
+ import { ChatModelStreamHandler, createContentAggregator } from '@/stream';
10
+ import { ToolEndHandler, ModelEndHandler } from '@/events';
11
+ import { GraphEvents, Providers } from '@/common';
12
+ import { Run } from '@/run';
13
+
14
+ const conversationHistory: BaseMessage[] = [];
15
+
16
+ /**
17
+ * Test: Tool call followed by handoff (role order validation)
18
+ *
19
+ * Reproduces the bug from issue #54:
20
+ * When a router agent runs a non-handoff tool (e.g. list_upload_sessions)
21
+ * and then hands off to another agent in the same turn, the receiving agent
22
+ * gets a message sequence of `... tool → user` which many chat APIs reject
23
+ * with: "400 Unexpected role 'user' after role 'tool'"
24
+ *
25
+ * The fix ensures handoff instructions are injected into the last ToolMessage
26
+ * (instead of appending a new HumanMessage) when the filtered messages end
27
+ * with a ToolMessage.
28
+ */
29
+ async function testToolBeforeHandoffRoleOrder(): Promise<void> {
30
+ console.log('='.repeat(60));
31
+ console.log('Test: Tool Call Before Handoff (Role Order Validation)');
32
+ console.log('='.repeat(60));
33
+ console.log('\nThis test verifies that:');
34
+ console.log('1. Router calls a regular tool AND then hands off');
35
+ console.log('2. The receiving agent does NOT get tool → user role sequence');
36
+ console.log('3. No 400 API error occurs after the handoff\n');
37
+
38
+ const { contentParts, aggregateContent } = createContentAggregator();
39
+
40
+ let currentAgent = '';
41
+ let toolCallCount = 0;
42
+ let handoffOccurred = false;
43
+
44
+ const customHandlers = {
45
+ [GraphEvents.TOOL_END]: new ToolEndHandler(undefined, (name?: string) => {
46
+ toolCallCount++;
47
+ console.log(`\n Tool completed: ${name} (total: ${toolCallCount})`);
48
+ return true;
49
+ }),
50
+ [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
51
+ [GraphEvents.CHAT_MODEL_STREAM]: new ChatModelStreamHandler(),
52
+ [GraphEvents.ON_RUN_STEP]: {
53
+ handle: (
54
+ event: GraphEvents.ON_RUN_STEP,
55
+ data: t.StreamEventData
56
+ ): void => {
57
+ const runStep = data as t.RunStep;
58
+ if (runStep.agentId) {
59
+ currentAgent = runStep.agentId;
60
+ console.log(`\n[Agent: ${currentAgent}] Processing...`);
61
+ }
62
+ aggregateContent({ event, data: runStep });
63
+ },
64
+ },
65
+ [GraphEvents.ON_RUN_STEP_COMPLETED]: {
66
+ handle: (
67
+ event: GraphEvents.ON_RUN_STEP_COMPLETED,
68
+ data: t.StreamEventData
69
+ ): void => {
70
+ aggregateContent({
71
+ event,
72
+ data: data as unknown as { result: t.ToolEndEvent },
73
+ });
74
+ },
75
+ },
76
+ [GraphEvents.ON_MESSAGE_DELTA]: {
77
+ handle: (
78
+ event: GraphEvents.ON_MESSAGE_DELTA,
79
+ data: t.StreamEventData
80
+ ): void => {
81
+ aggregateContent({ event, data: data as t.MessageDeltaEvent });
82
+ },
83
+ },
84
+ [GraphEvents.TOOL_START]: {
85
+ handle: (
86
+ _event: string,
87
+ data: t.StreamEventData,
88
+ _metadata?: Record<string, unknown>
89
+ ): void => {
90
+ const toolData = data as { name?: string };
91
+ if (toolData?.name?.includes('transfer_to_')) {
92
+ handoffOccurred = true;
93
+ const specialist = toolData.name.replace('lc_transfer_to_', '');
94
+ console.log(`\n Handoff initiated to: ${specialist}`);
95
+ }
96
+ },
97
+ },
98
+ };
99
+
100
+ /**
101
+ * Create a simple tool for the router agent.
102
+ * This simulates the list_upload_sessions scenario from issue #54:
103
+ * the router calls a regular tool and THEN hands off in the same turn.
104
+ */
105
+ const listSessions = tool(
106
+ async () => {
107
+ return JSON.stringify({
108
+ sessions: [
109
+ { id: 'sess_1', name: 'Q4 Report', status: 'ready' },
110
+ { id: 'sess_2', name: 'Budget Analysis', status: 'pending' },
111
+ ],
112
+ });
113
+ },
114
+ {
115
+ name: 'list_upload_sessions',
116
+ description: 'List available upload sessions for data analysis',
117
+ schema: z.object({}),
118
+ }
119
+ );
120
+
121
+ const agents: t.AgentInputs[] = [
122
+ {
123
+ agentId: 'router',
124
+ provider: Providers.OPENAI,
125
+ clientOptions: {
126
+ modelName: 'gpt-4.1-mini',
127
+ apiKey: process.env.OPENAI_API_KEY,
128
+ },
129
+ tools: [listSessions],
130
+ instructions: `You are a Router agent with access to upload sessions and a data analysis specialist.
131
+
132
+ Your workflow for data-related requests:
133
+ 1. FIRST: Call list_upload_sessions to check available data
134
+ 2. THEN: Transfer to the data_analyst with your findings
135
+
136
+ CRITICAL: You MUST call list_upload_sessions first, then immediately transfer to data_analyst.
137
+ Do NOT write a long response. Just call the tool and hand off.`,
138
+ maxContextTokens: 8000,
139
+ },
140
+ {
141
+ agentId: 'data_analyst',
142
+ provider: Providers.OPENAI,
143
+ clientOptions: {
144
+ modelName: 'gpt-4.1-mini',
145
+ apiKey: process.env.OPENAI_API_KEY,
146
+ },
147
+ instructions: `You are a Data Analyst specialist. When you receive a request:
148
+ 1. Review any data or context provided
149
+ 2. Provide a concise analysis or recommendation
150
+ 3. Keep your response brief and focused`,
151
+ maxContextTokens: 8000,
152
+ },
153
+ ];
154
+
155
+ const edges: t.GraphEdge[] = [
156
+ {
157
+ from: 'router',
158
+ to: 'data_analyst',
159
+ description: 'Transfer to data analyst after checking sessions',
160
+ edgeType: 'handoff',
161
+ prompt:
162
+ 'Provide specific instructions for the data analyst about what to analyze',
163
+ promptKey: 'instructions',
164
+ },
165
+ ];
166
+
167
+ const runConfig: t.RunConfig = {
168
+ runId: `tool-before-handoff-role-order-${Date.now()}`,
169
+ graphConfig: {
170
+ type: 'multi-agent',
171
+ agents,
172
+ edges,
173
+ },
174
+ customHandlers,
175
+ returnContent: true,
176
+ };
177
+
178
+ const run = await Run.create(runConfig);
179
+
180
+ const streamConfig: Partial<RunnableConfig> & {
181
+ version: 'v1' | 'v2';
182
+ streamMode: string;
183
+ } = {
184
+ configurable: {
185
+ thread_id: 'tool-before-handoff-role-order-1',
186
+ },
187
+ streamMode: 'values',
188
+ version: 'v2' as const,
189
+ };
190
+
191
+ try {
192
+ const query =
193
+ 'I want to visualize my CSV data. Can you check what upload sessions are available and have the analyst help me?';
194
+
195
+ console.log('\n' + '-'.repeat(60));
196
+ console.log(`USER QUERY: "${query}"`);
197
+ console.log('-'.repeat(60));
198
+ console.log('\nExpected behavior:');
199
+ console.log('1. Router calls list_upload_sessions tool');
200
+ console.log('2. Router hands off to data_analyst');
201
+ console.log('3. data_analyst responds WITHOUT 400 error\n');
202
+
203
+ conversationHistory.push(new HumanMessage(query));
204
+ const inputs = { messages: conversationHistory };
205
+
206
+ await run.processStream(inputs, streamConfig);
207
+ const finalMessages = run.getRunMessages();
208
+ if (finalMessages) {
209
+ conversationHistory.push(...finalMessages);
210
+ }
211
+
212
+ /** Results */
213
+ console.log(`\n${'='.repeat(60)}`);
214
+ console.log('TEST RESULTS:');
215
+ console.log('='.repeat(60));
216
+ console.log(`Tool calls made: ${toolCallCount}`);
217
+ console.log(`Handoff occurred: ${handoffOccurred ? 'Yes' : 'No'}`);
218
+ console.log(
219
+ `Test status: ${toolCallCount > 0 && handoffOccurred ? 'PASSED' : 'FAILED'}`
220
+ );
221
+
222
+ if (toolCallCount === 0) {
223
+ console.log('\nNote: Router did not call any tools before handoff.');
224
+ console.log(
225
+ 'The bug only occurs when a non-handoff tool is called in the same turn as the handoff.'
226
+ );
227
+ console.log('Try running again - the model may need stronger prompting.');
228
+ }
229
+
230
+ console.log('='.repeat(60));
231
+
232
+ /** Show conversation history */
233
+ console.log('\nConversation History:');
234
+ console.log('-'.repeat(60));
235
+ conversationHistory.forEach((msg, idx) => {
236
+ const role = msg.getType();
237
+ const content =
238
+ typeof msg.content === 'string'
239
+ ? msg.content.substring(0, 150) +
240
+ (msg.content.length > 150 ? '...' : '')
241
+ : '[complex content]';
242
+ console.log(` [${idx}] ${role}: ${content}`);
243
+ });
244
+ } catch (error) {
245
+ const errorMsg = error instanceof Error ? error.message : String(error);
246
+ console.error('\nTest FAILED with error:', errorMsg);
247
+
248
+ if (errorMsg.includes('Unexpected role') || errorMsg.includes('400')) {
249
+ console.error('\n>>> This is the exact bug from issue #54! <<<<');
250
+ console.error(
251
+ '>>> The tool→user role sequence caused a 400 API error. <<<'
252
+ );
253
+ }
254
+
255
+ console.log('\nConversation history at failure:');
256
+ console.dir(conversationHistory, { depth: null });
257
+ }
258
+ }
259
+
260
+ process.on('unhandledRejection', (reason, promise) => {
261
+ console.error('Unhandled Rejection at:', promise, 'reason:', reason);
262
+ console.log('\nConversation history at failure:');
263
+ console.dir(conversationHistory, { depth: null });
264
+ process.exit(1);
265
+ });
266
+
267
+ process.on('uncaughtException', (err) => {
268
+ console.error('Uncaught Exception:', err);
269
+ });
270
+
271
+ testToolBeforeHandoffRoleOrder().catch((err) => {
272
+ console.error('Test failed:', err);
273
+ console.log('\nConversation history at failure:');
274
+ console.dir(conversationHistory, { depth: null });
275
+ process.exit(1);
276
+ });