@librechat/agents 3.1.41 → 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.
@@ -786,13 +786,39 @@ export class MultiAgentGraph extends StandardGraph {
786
786
  /** Build messages for the receiving agent */
787
787
  let messagesForAgent = filteredMessages;
788
788
 
789
- /** If there are instructions, inject them as a HumanMessage to ground the agent */
789
+ /**
790
+ * If there are instructions, inject them as a HumanMessage to
791
+ * ground the receiving agent.
792
+ *
793
+ * When the last filtered message is a ToolMessage (e.g. from a
794
+ * non-handoff tool the router called before handing off), a
795
+ * synthetic AIMessage is inserted first to satisfy the
796
+ * tool → assistant role ordering required by chat APIs. Without
797
+ * this bridge, appending a HumanMessage directly after a
798
+ * ToolMessage causes "400 Unexpected role 'user' after role
799
+ * 'tool'" errors (see issue #54).
800
+ */
790
801
  const hasInstructions = instructions !== null && instructions !== '';
791
802
  if (hasInstructions) {
792
- messagesForAgent = [
793
- ...filteredMessages,
794
- new HumanMessage(instructions),
795
- ];
803
+ const lastMsg =
804
+ filteredMessages.length > 0
805
+ ? filteredMessages[filteredMessages.length - 1]
806
+ : null;
807
+
808
+ if (lastMsg != null && lastMsg.getType() === 'tool') {
809
+ messagesForAgent = [
810
+ ...filteredMessages,
811
+ new AIMessage(
812
+ `[Processed tool result and transferring to ${agentId}]`
813
+ ),
814
+ new HumanMessage(instructions),
815
+ ];
816
+ } else {
817
+ messagesForAgent = [
818
+ ...filteredMessages,
819
+ new HumanMessage(instructions),
820
+ ];
821
+ }
796
822
  }
797
823
 
798
824
  /** Update token map if we have a token counter */
@@ -808,10 +834,14 @@ export class MultiAgentGraph extends StandardGraph {
808
834
  freshTokenMap[i] = tokenCount;
809
835
  }
810
836
  }
811
- /** Add tokens for the instructions message */
812
- const instructionsMsg = new HumanMessage(instructions);
813
- freshTokenMap[messagesForAgent.length - 1] =
814
- agentContext.tokenCounter(instructionsMsg);
837
+ /** Add tokens for the bridge AIMessage + instructions HumanMessage */
838
+ for (
839
+ let i = filteredMessages.length;
840
+ i < messagesForAgent.length;
841
+ i++
842
+ ) {
843
+ freshTokenMap[i] = agentContext.tokenCounter(messagesForAgent[i]);
844
+ }
815
845
  agentContext.updateTokenMapWithInstructions(freshTokenMap);
816
846
  }
817
847
 
@@ -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
+ });