@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.
@@ -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
+ });
@@ -821,6 +821,112 @@ describe('Agent Handoffs Tests', () => {
821
821
  });
822
822
  });
823
823
 
824
+ describe('Tool Call Before Handoff (Issue #54)', () => {
825
+ it('should complete handoff when router calls a non-handoff tool in the same turn', async () => {
826
+ /**
827
+ * Reproduces the bug from issue #54:
828
+ * When a router calls a regular tool AND a handoff tool in the same turn,
829
+ * the filtered messages for the receiving agent end with a ToolMessage.
830
+ * Previously, instructions were appended as a HumanMessage (tool → user),
831
+ * which many APIs reject. The fix injects instructions into the last
832
+ * ToolMessage instead.
833
+ */
834
+ const customTool = new DynamicStructuredTool({
835
+ name: 'list_upload_sessions',
836
+ description: 'List available upload sessions',
837
+ schema: { type: 'object', properties: {}, required: [] },
838
+ func: async (): Promise<string> =>
839
+ JSON.stringify({ sessions: [{ id: 'sess_1', status: 'ready' }] }),
840
+ });
841
+
842
+ const agents: t.AgentInputs[] = [
843
+ {
844
+ ...createBasicAgent('router', 'You are a router'),
845
+ tools: [customTool],
846
+ toolMap: new Map([['list_upload_sessions', customTool]]) as t.ToolMap,
847
+ },
848
+ createBasicAgent('data_analyst', 'You are a data analyst'),
849
+ ];
850
+
851
+ const edges: t.GraphEdge[] = [
852
+ {
853
+ from: 'router',
854
+ to: 'data_analyst',
855
+ edgeType: 'handoff',
856
+ description: 'Transfer to data analyst',
857
+ prompt: 'Instructions for the analyst about what to analyze',
858
+ promptKey: 'instructions',
859
+ },
860
+ ];
861
+
862
+ const run = await Run.create(createTestConfig(agents, edges));
863
+
864
+ /**
865
+ * Simulate router calling list_upload_sessions AND handoff in the same turn.
866
+ * The first model response includes both tool calls.
867
+ * The second model response is the data_analyst's reply.
868
+ */
869
+ run.Graph?.overrideTestModel(
870
+ [
871
+ 'Checking available sessions and transferring to analyst',
872
+ 'Here is my analysis of the available sessions',
873
+ ],
874
+ 10,
875
+ [
876
+ {
877
+ id: 'tool_call_1',
878
+ name: 'list_upload_sessions',
879
+ args: {},
880
+ } as ToolCall,
881
+ {
882
+ id: 'tool_call_2',
883
+ name: `${Constants.LC_TRANSFER_TO_}data_analyst`,
884
+ args: { instructions: 'Analyze the upload session data' },
885
+ } as ToolCall,
886
+ ]
887
+ );
888
+
889
+ const messages = [
890
+ new HumanMessage('Check my upload sessions and analyze them'),
891
+ ];
892
+
893
+ const config: Partial<RunnableConfig> & {
894
+ version: 'v1' | 'v2';
895
+ streamMode: string;
896
+ } = {
897
+ configurable: {
898
+ thread_id: 'test-tool-before-handoff-thread',
899
+ },
900
+ streamMode: 'values',
901
+ version: 'v2' as const,
902
+ };
903
+
904
+ /**
905
+ * This should complete without error. Before the fix, the receiving
906
+ * agent would get an invalid tool → user message sequence.
907
+ */
908
+ await run.processStream({ messages }, config);
909
+
910
+ const finalMessages = run.getRunMessages();
911
+ expect(finalMessages).toBeDefined();
912
+ expect(finalMessages!.length).toBeGreaterThan(1);
913
+
914
+ /** Verify that the handoff occurred */
915
+ const toolMessages = finalMessages!.filter(
916
+ (msg) => msg.getType() === 'tool'
917
+ ) as ToolMessage[];
918
+
919
+ const handoffMessage = toolMessages.find(
920
+ (msg) => msg.name === `${Constants.LC_TRANSFER_TO_}data_analyst`
921
+ );
922
+ expect(handoffMessage).toBeDefined();
923
+
924
+ /** Verify the flow completed (agent B responded) */
925
+ const aiMessages = finalMessages!.filter((msg) => msg.getType() === 'ai');
926
+ expect(aiMessages.length).toBeGreaterThanOrEqual(1);
927
+ });
928
+ });
929
+
824
930
  describe('Handoff Tool Naming', () => {
825
931
  it('should use correct naming convention for handoff tools', async () => {
826
932
  const agents: t.AgentInputs[] = [