@librechat/agents 3.1.87 → 3.1.89

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.
@@ -117,6 +117,276 @@ describe('ToolNode eager event tool execution', () => {
117
117
  expect(result.messages[0].content).toBe('eager result');
118
118
  });
119
119
 
120
+ it('uses a prestarted event result when final args are canonically equivalent', async () => {
121
+ const { toolExecuteCalls } = installToolExecuteResponder('redispatched');
122
+ const eagerExecutions = new Map<string, t.EagerEventToolExecution>();
123
+ const request: t.ToolCallRequest = {
124
+ id: 'call_weather',
125
+ name: 'weather',
126
+ args: { city: 'NYC', units: 'metric' },
127
+ stepId: 'step_weather',
128
+ turn: 0,
129
+ };
130
+ eagerExecutions.set('call_weather', {
131
+ toolCallId: 'call_weather',
132
+ toolName: 'weather',
133
+ args: { city: 'NYC', units: 'metric' },
134
+ request,
135
+ promise: Promise.resolve({
136
+ results: [
137
+ {
138
+ toolCallId: 'call_weather',
139
+ status: 'success',
140
+ content: 'eager result',
141
+ },
142
+ ],
143
+ }),
144
+ });
145
+
146
+ const toolNode = new ToolNode({
147
+ tools: [createDummyTool('weather')],
148
+ eventDrivenMode: true,
149
+ eagerEventToolExecution: { enabled: true },
150
+ eagerEventToolExecutions: eagerExecutions,
151
+ toolCallStepIds: new Map([['call_weather', 'step_weather']]),
152
+ });
153
+
154
+ const result = (await toolNode.invoke({
155
+ messages: [
156
+ createAIMessage('call_weather', 'weather', {
157
+ units: 'metric',
158
+ city: 'NYC',
159
+ }),
160
+ ],
161
+ })) as { messages: ToolMessage[] };
162
+
163
+ expect(toolExecuteCalls).toHaveLength(0);
164
+ expect(eagerExecutions.has('call_weather')).toBe(false);
165
+ expect(result.messages).toHaveLength(1);
166
+ expect(result.messages[0].content).toBe('eager result');
167
+ });
168
+
169
+ it('uses a prestarted event result when only final planning turn differs', async () => {
170
+ const { toolExecuteCalls } = installToolExecuteResponder('redispatched');
171
+ const eagerExecutions = new Map<string, t.EagerEventToolExecution>();
172
+ const eagerUsageCount = new Map<string, number>([['weather', 1]]);
173
+ const request: t.ToolCallRequest = {
174
+ id: 'call_weather',
175
+ name: 'weather',
176
+ args: { city: 'NYC' },
177
+ stepId: 'step_weather',
178
+ turn: 7,
179
+ };
180
+ eagerExecutions.set('call_weather', {
181
+ toolCallId: 'call_weather',
182
+ toolName: 'weather',
183
+ args: { city: 'NYC' },
184
+ request,
185
+ promise: Promise.resolve({
186
+ results: [
187
+ {
188
+ toolCallId: 'call_weather',
189
+ status: 'success',
190
+ content: 'eager result',
191
+ },
192
+ ],
193
+ }),
194
+ });
195
+
196
+ const toolNode = new ToolNode({
197
+ tools: [createDummyTool('weather')],
198
+ eventDrivenMode: true,
199
+ eagerEventToolExecution: { enabled: true },
200
+ eagerEventToolExecutions: eagerExecutions,
201
+ eagerEventToolUsageCount: eagerUsageCount,
202
+ toolCallStepIds: new Map([['call_weather', 'step_weather']]),
203
+ });
204
+
205
+ const result = (await toolNode.invoke({
206
+ messages: [createAIMessage('call_weather', 'weather', { city: 'NYC' })],
207
+ })) as { messages: ToolMessage[] };
208
+
209
+ expect(toolExecuteCalls).toHaveLength(0);
210
+ expect(eagerExecutions.has('call_weather')).toBe(false);
211
+ expect(eagerUsageCount.get('weather')).toBe(1);
212
+ expect(result.messages).toHaveLength(1);
213
+ expect(result.messages[0].status).toBe('success');
214
+ expect(result.messages[0].content).toBe('eager result');
215
+ });
216
+
217
+ it('redispatches completion when a prestarted event result used a stale turn', async () => {
218
+ const stepCompletions: Array<{
219
+ result?: { index?: number; tool_call?: { output?: string } };
220
+ }> = [];
221
+ jest
222
+ .spyOn(events, 'safeDispatchCustomEvent')
223
+ .mockImplementation(async (event, data): Promise<boolean | void> => {
224
+ if (event === GraphEvents.ON_RUN_STEP_COMPLETED) {
225
+ stepCompletions.push(data as (typeof stepCompletions)[number]);
226
+ return true;
227
+ }
228
+ if (event === GraphEvents.ON_TOOL_EXECUTE) {
229
+ throw new Error('tool should not redispatch');
230
+ }
231
+ });
232
+
233
+ const eagerExecutions = new Map<string, t.EagerEventToolExecution>();
234
+ const eagerUsageCount = new Map<string, number>([['weather', 1]]);
235
+ const request: t.ToolCallRequest = {
236
+ id: 'call_weather',
237
+ name: 'weather',
238
+ args: { city: 'NYC' },
239
+ stepId: 'step_weather',
240
+ turn: 7,
241
+ };
242
+ eagerExecutions.set('call_weather', {
243
+ toolCallId: 'call_weather',
244
+ toolName: 'weather',
245
+ args: { city: 'NYC' },
246
+ request,
247
+ completionDispatched: true,
248
+ promise: Promise.resolve({
249
+ results: [
250
+ {
251
+ toolCallId: 'call_weather',
252
+ status: 'success',
253
+ content: 'eager result',
254
+ },
255
+ ],
256
+ }),
257
+ });
258
+
259
+ const toolNode = new ToolNode({
260
+ tools: [createDummyTool('weather')],
261
+ eventDrivenMode: true,
262
+ eagerEventToolExecution: { enabled: true },
263
+ eagerEventToolExecutions: eagerExecutions,
264
+ eagerEventToolUsageCount: eagerUsageCount,
265
+ toolCallStepIds: new Map([['call_weather', 'step_weather']]),
266
+ });
267
+
268
+ const result = (await toolNode.invoke({
269
+ messages: [createAIMessage('call_weather', 'weather', { city: 'NYC' })],
270
+ })) as { messages: ToolMessage[] };
271
+
272
+ expect(stepCompletions).toHaveLength(1);
273
+ expect(stepCompletions[0].result?.index).toBe(0);
274
+ expect(stepCompletions[0].result?.tool_call?.output).toBe('eager result');
275
+ expect(result.messages).toHaveLength(1);
276
+ expect(result.messages[0].content).toBe('eager result');
277
+ });
278
+
279
+ it('uses a matching prestarted event result when output references are enabled', async () => {
280
+ const { toolExecuteCalls } = installToolExecuteResponder('redispatched');
281
+ const eagerExecutions = new Map<string, t.EagerEventToolExecution>();
282
+ const eagerUsageCount = new Map<string, number>([['weather', 1]]);
283
+ const request: t.ToolCallRequest = {
284
+ id: 'call_weather',
285
+ name: 'weather',
286
+ args: { city: 'NYC' },
287
+ stepId: 'step_weather',
288
+ turn: 0,
289
+ };
290
+ eagerExecutions.set('call_weather', {
291
+ toolCallId: 'call_weather',
292
+ toolName: 'weather',
293
+ args: { city: 'NYC' },
294
+ request,
295
+ promise: Promise.resolve({
296
+ results: [
297
+ {
298
+ toolCallId: 'call_weather',
299
+ status: 'success',
300
+ content: 'eager result',
301
+ },
302
+ ],
303
+ }),
304
+ });
305
+
306
+ const toolNode = new ToolNode({
307
+ tools: [createDummyTool('weather')],
308
+ eventDrivenMode: true,
309
+ eagerEventToolExecution: { enabled: true },
310
+ eagerEventToolExecutions: eagerExecutions,
311
+ eagerEventToolUsageCount: eagerUsageCount,
312
+ toolCallStepIds: new Map([['call_weather', 'step_weather']]),
313
+ toolOutputReferences: { enabled: true },
314
+ });
315
+
316
+ const result = (await toolNode.invoke(
317
+ {
318
+ messages: [createAIMessage('call_weather', 'weather', { city: 'NYC' })],
319
+ },
320
+ { configurable: { run_id: 'run_1' } }
321
+ )) as { messages: ToolMessage[] };
322
+
323
+ expect(toolExecuteCalls).toHaveLength(0);
324
+ expect(eagerExecutions.has('call_weather')).toBe(false);
325
+ expect(result.messages).toHaveLength(1);
326
+ expect(result.messages[0].content).toBe('eager result');
327
+ expect(result.messages[0].additional_kwargs._refKey).toBe('tool0turn0');
328
+ expect(result.messages[0].additional_kwargs._refScope).toBe('run_1');
329
+ });
330
+
331
+ it('does not redispatch completion when eager path already emitted it', async () => {
332
+ const completedEvents: Array<{ result: t.ToolEndEvent }> = [];
333
+ const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
334
+ jest
335
+ .spyOn(events, 'safeDispatchCustomEvent')
336
+ .mockImplementation(async (event, data): Promise<void> => {
337
+ if (event === GraphEvents.ON_RUN_STEP_COMPLETED) {
338
+ completedEvents.push(data as { result: t.ToolEndEvent });
339
+ return;
340
+ }
341
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
342
+ return;
343
+ }
344
+ toolExecuteCalls.push(data as t.ToolExecuteBatchRequest);
345
+ });
346
+
347
+ const eagerExecutions = new Map<string, t.EagerEventToolExecution>();
348
+ const request: t.ToolCallRequest = {
349
+ id: 'call_weather',
350
+ name: 'weather',
351
+ args: { city: 'NYC' },
352
+ stepId: 'step_weather',
353
+ turn: 0,
354
+ };
355
+ eagerExecutions.set('call_weather', {
356
+ toolCallId: 'call_weather',
357
+ toolName: 'weather',
358
+ args: { city: 'NYC' },
359
+ request,
360
+ completionDispatched: true,
361
+ promise: Promise.resolve({
362
+ results: [
363
+ {
364
+ toolCallId: 'call_weather',
365
+ status: 'success',
366
+ content: 'eager result',
367
+ },
368
+ ],
369
+ }),
370
+ });
371
+
372
+ const toolNode = new ToolNode({
373
+ tools: [createDummyTool('weather')],
374
+ eventDrivenMode: true,
375
+ eagerEventToolExecution: { enabled: true },
376
+ eagerEventToolExecutions: eagerExecutions,
377
+ eagerEventToolUsageCount: new Map([['weather', 1]]),
378
+ toolCallStepIds: new Map([['call_weather', 'step_weather']]),
379
+ });
380
+
381
+ const result = (await toolNode.invoke({
382
+ messages: [createAIMessage('call_weather', 'weather', { city: 'NYC' })],
383
+ })) as { messages: ToolMessage[] };
384
+
385
+ expect(toolExecuteCalls).toHaveLength(0);
386
+ expect(completedEvents).toHaveLength(0);
387
+ expect(result.messages[0].content).toBe('eager result');
388
+ });
389
+
120
390
  it('fails closed without redispatching when final args differ from the prestarted call', async () => {
121
391
  const { toolExecuteCalls } = installToolExecuteResponder('fresh result');
122
392
  const eagerExecutions = new Map<string, t.EagerEventToolExecution>();
@@ -196,7 +466,7 @@ describe('ToolNode eager event tool execution', () => {
196
466
  const { toolExecuteCalls } = installToolExecuteResponder('normal result');
197
467
  const eagerExecutions = new Map<string, t.EagerEventToolExecution>();
198
468
  const eagerUsageCount = new Map<string, number>([['weather', 1]]);
199
- const staleRequest: t.ToolCallRequest = {
469
+ const prestartedRequest: t.ToolCallRequest = {
200
470
  id: 'call_weather_2',
201
471
  name: 'weather',
202
472
  args: { city: 'Boston' },
@@ -207,13 +477,13 @@ describe('ToolNode eager event tool execution', () => {
207
477
  toolCallId: 'call_weather_2',
208
478
  toolName: 'weather',
209
479
  args: { city: 'Boston' },
210
- request: staleRequest,
480
+ request: prestartedRequest,
211
481
  promise: Promise.resolve({
212
482
  results: [
213
483
  {
214
484
  toolCallId: 'call_weather_2',
215
485
  status: 'success',
216
- content: 'stale eager result',
486
+ content: 'prestarted eager result',
217
487
  },
218
488
  ],
219
489
  }),
@@ -263,8 +533,8 @@ describe('ToolNode eager event tool execution', () => {
263
533
  'call_weather_2',
264
534
  ]);
265
535
  expect(result.messages[0].status).toBe('success');
266
- expect(result.messages[1].status).toBe('error');
267
- expect(result.messages[1].content).toContain('refusing to re-run');
536
+ expect(result.messages[1].status).toBe('success');
537
+ expect(result.messages[1].content).toBe('prestarted eager result');
268
538
  });
269
539
 
270
540
  it('returns a per-call error for malformed event tool args without aborting the batch', async () => {
@@ -314,9 +584,7 @@ describe('ToolNode eager event tool execution', () => {
314
584
  'call_weather_good',
315
585
  ]);
316
586
  expect(result.messages[0].status).toBe('error');
317
- expect(result.messages[0].content).toContain(
318
- 'Invalid tool call arguments'
319
- );
587
+ expect(result.messages[0].content).toContain('Invalid tool call arguments');
320
588
  expect(result.messages[1].status).toBe('success');
321
589
  expect(result.messages[1].content).toBe('normal result');
322
590
  });
@@ -328,9 +596,7 @@ describe('ToolNode eager event tool execution', () => {
328
596
  const hookRegistry = new HookRegistry();
329
597
  hookRegistry.register('PreToolUse', {
330
598
  hooks: [
331
- async (
332
- input: PreToolUseHookInput
333
- ): Promise<PreToolUseHookOutput> => {
599
+ async (input: PreToolUseHookInput): Promise<PreToolUseHookOutput> => {
334
600
  preToolTurns.push(input.turn);
335
601
  return { decision: 'allow' };
336
602
  },
@@ -351,9 +617,7 @@ describe('ToolNode eager event tool execution', () => {
351
617
  });
352
618
 
353
619
  await toolNode.invoke({
354
- messages: [
355
- createAIMessage('call_weather_1', 'weather', { city: 'NYC' }),
356
- ],
620
+ messages: [createAIMessage('call_weather_1', 'weather', { city: 'NYC' })],
357
621
  });
358
622
 
359
623
  eagerUsageCount.clear();
@@ -49,6 +49,13 @@ export type EagerEventToolExecution = {
49
49
  args: Record<string, unknown>;
50
50
  request: ToolCallRequest;
51
51
  promise: Promise<EagerEventToolExecutionOutcome>;
52
+ /**
53
+ * True when the streaming eager path already emitted the user-visible
54
+ * ON_RUN_STEP_COMPLETED event for this call. ToolNode still consumes the
55
+ * result later to mutate graph state in provider-safe order, but skips
56
+ * duplicate completion emission.
57
+ */
58
+ completionDispatched?: boolean;
52
59
  };
53
60
 
54
61
  export type EagerEventToolCallChunkState = {
@@ -172,6 +179,8 @@ export type ToolEndEvent = {
172
179
  /** The content index of the tool call */
173
180
  index: number;
174
181
  type?: 'tool_call';
182
+ /** True when the stream eager path surfaced this completion before ToolNode finalized graph state. */
183
+ eager?: boolean;
175
184
  };
176
185
 
177
186
  /**
@@ -13,9 +13,10 @@ export async function safeDispatchCustomEvent(
13
13
  event: string,
14
14
  payload: unknown,
15
15
  config?: RunnableConfig
16
- ): Promise<void> {
16
+ ): Promise<boolean | void> {
17
17
  try {
18
18
  await dispatchCustomEvent(event, payload, config);
19
+ return true;
19
20
  } catch (e) {
20
21
  // Check if this is the known EventStreamCallbackHandler error
21
22
  if (
@@ -26,10 +27,11 @@ export async function safeDispatchCustomEvent(
26
27
  // Suppress this specific error - it's expected during parallel execution
27
28
  // when EventStreamCallbackHandler loses track of run IDs
28
29
  // console.debug('Suppressed error dispatching custom event:', e);
29
- return;
30
+ return false;
30
31
  }
31
32
  // Log other errors
32
33
  console.error('Error dispatching custom event:', e);
34
+ return false;
33
35
  }
34
36
  }
35
37