@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.
@@ -11,7 +11,7 @@ import {
11
11
  } from '@/common';
12
12
  import { HandlerRegistry } from '@/events';
13
13
  import * as events from '@/utils/events';
14
- import { ChatModelStreamHandler } from '@/stream';
14
+ import { ChatModelStreamHandler, createContentAggregator } from '@/stream';
15
15
  import {
16
16
  STREAMED_TOOL_CALL_SEAL_METADATA_KEY,
17
17
  STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY,
@@ -71,7 +71,8 @@ function createGraph(overrides: Partial<StandardGraph> = {}): StandardGraph {
71
71
  (details as t.StepDetails).type === StepTypes.TOOL_CALLS &&
72
72
  Array.isArray((details as t.ToolCallsDetails).tool_calls)
73
73
  ) {
74
- for (const toolCall of (details as t.ToolCallsDetails).tool_calls ?? []) {
74
+ for (const toolCall of (details as t.ToolCallsDetails).tool_calls ??
75
+ []) {
75
76
  if (toolCall.id != null && toolCall.id !== '') {
76
77
  graph.toolCallStepIds.set(toolCall.id, id);
77
78
  }
@@ -110,8 +111,9 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
110
111
  it('prestarts a complete event-driven tool call from the stream', async () => {
111
112
  const graph = createGraph();
112
113
  const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
113
- jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
114
- async (event, data): Promise<void> => {
114
+ jest
115
+ .spyOn(events, 'safeDispatchCustomEvent')
116
+ .mockImplementation(async (event, data): Promise<void> => {
115
117
  if (event !== GraphEvents.ON_TOOL_EXECUTE) {
116
118
  return;
117
119
  }
@@ -124,8 +126,7 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
124
126
  content: 'sunny',
125
127
  },
126
128
  ]);
127
- }
128
- );
129
+ });
129
130
 
130
131
  await new ChatModelStreamHandler().handle(
131
132
  GraphEvents.CHAT_MODEL_STREAM,
@@ -162,11 +163,65 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
162
163
  expect(graph.toolCallStepIds.has('call_weather')).toBe(true);
163
164
  });
164
165
 
166
+ it('prestarts when subagent callback forwarding can execute tools without a handler registry', async () => {
167
+ const graph = createGraph({
168
+ handlerRegistry: undefined,
169
+ eventToolExecutionAvailable: true,
170
+ });
171
+ const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
172
+ jest
173
+ .spyOn(events, 'safeDispatchCustomEvent')
174
+ .mockImplementation(async (event, data): Promise<void> => {
175
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
176
+ return;
177
+ }
178
+ const batch = data as t.ToolExecuteBatchRequest;
179
+ toolExecuteCalls.push(batch);
180
+ batch.resolve([
181
+ {
182
+ toolCallId: 'call_weather',
183
+ status: 'success',
184
+ content: 'sunny',
185
+ },
186
+ ]);
187
+ });
188
+
189
+ await new ChatModelStreamHandler().handle(
190
+ GraphEvents.CHAT_MODEL_STREAM,
191
+ {
192
+ chunk: {
193
+ content: '',
194
+ tool_calls: [
195
+ {
196
+ id: 'call_weather',
197
+ name: 'weather',
198
+ args: { city: 'NYC' },
199
+ },
200
+ ],
201
+ response_metadata: finalToolCallResponseMetadata,
202
+ } as unknown as t.StreamChunk,
203
+ },
204
+ { langgraph_node: 'agent' },
205
+ graph
206
+ );
207
+
208
+ expect(toolExecuteCalls).toHaveLength(1);
209
+ expect(toolExecuteCalls[0].toolCalls[0]).toMatchObject({
210
+ id: 'call_weather',
211
+ name: 'weather',
212
+ args: { city: 'NYC' },
213
+ stepId: expect.stringMatching(/^step_/),
214
+ turn: 0,
215
+ });
216
+ expect(graph.eagerEventToolExecutions.has('call_weather')).toBe(true);
217
+ });
218
+
165
219
  it('does not prestart parseable tool calls before a final tool-call signal', async () => {
166
220
  const graph = createGraph();
167
221
  const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
168
- jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
169
- async (event, data): Promise<void> => {
222
+ jest
223
+ .spyOn(events, 'safeDispatchCustomEvent')
224
+ .mockImplementation(async (event, data): Promise<void> => {
170
225
  if (event !== GraphEvents.ON_TOOL_EXECUTE) {
171
226
  return;
172
227
  }
@@ -179,8 +234,7 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
179
234
  content: 'sunny',
180
235
  },
181
236
  ]);
182
- }
183
- );
237
+ });
184
238
 
185
239
  const handler = new ChatModelStreamHandler();
186
240
  const metadata = { langgraph_node: 'agent' };
@@ -248,8 +302,9 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
248
302
  ) as unknown as StandardGraph['getAgentContext'],
249
303
  });
250
304
  const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
251
- jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
252
- async (event, data): Promise<void> => {
305
+ jest
306
+ .spyOn(events, 'safeDispatchCustomEvent')
307
+ .mockImplementation(async (event, data): Promise<void> => {
253
308
  if (event !== GraphEvents.ON_TOOL_EXECUTE) {
254
309
  return;
255
310
  }
@@ -262,8 +317,7 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
262
317
  content: `${request.name} result`,
263
318
  }))
264
319
  );
265
- }
266
- );
320
+ });
267
321
 
268
322
  await new ChatModelStreamHandler().handle(
269
323
  GraphEvents.CHAT_MODEL_STREAM,
@@ -308,7 +362,8 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
308
362
  }),
309
363
  ]);
310
364
  const weatherExecution = graph.eagerEventToolExecutions.get('call_weather');
311
- const calendarExecution = graph.eagerEventToolExecutions.get('call_calendar');
365
+ const calendarExecution =
366
+ graph.eagerEventToolExecutions.get('call_calendar');
312
367
  expect(weatherExecution).toMatchObject({
313
368
  toolCallId: 'call_weather',
314
369
  toolName: 'weather',
@@ -325,8 +380,9 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
325
380
  it('assigns same-tool eager turns in model emission order', async () => {
326
381
  const graph = createGraph();
327
382
  const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
328
- jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
329
- async (event, data): Promise<void> => {
383
+ jest
384
+ .spyOn(events, 'safeDispatchCustomEvent')
385
+ .mockImplementation(async (event, data): Promise<void> => {
330
386
  if (event !== GraphEvents.ON_TOOL_EXECUTE) {
331
387
  return;
332
388
  }
@@ -339,8 +395,7 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
339
395
  content: `${request.args.city} weather`,
340
396
  }))
341
397
  );
342
- }
343
- );
398
+ });
344
399
 
345
400
  await new ChatModelStreamHandler().handle(
346
401
  GraphEvents.CHAT_MODEL_STREAM,
@@ -371,10 +426,12 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
371
426
  0, 1,
372
427
  ]);
373
428
  expect(graph.eagerEventToolUsageCount.get('weather')).toBe(2);
374
- expect(graph.eagerEventToolExecutions.get('call_weather_1')?.request.turn)
375
- .toBe(0);
376
- expect(graph.eagerEventToolExecutions.get('call_weather_2')?.request.turn)
377
- .toBe(1);
429
+ expect(
430
+ graph.eagerEventToolExecutions.get('call_weather_1')?.request.turn
431
+ ).toBe(0);
432
+ expect(
433
+ graph.eagerEventToolExecutions.get('call_weather_2')?.request.turn
434
+ ).toBe(1);
378
435
  });
379
436
 
380
437
  it('scopes eager turn reservation by agent', async () => {
@@ -391,19 +448,21 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
391
448
  const graph = createGraph({
392
449
  getEagerEventToolUsageCount: jest.fn(getUsageCount),
393
450
  getAgentContext: jest.fn(
394
- (metadata?: Record<string, unknown>): AgentContext => ({
395
- provider: Providers.ANTHROPIC,
396
- reasoningKey: 'reasoning',
397
- toolDefinitions: [{ name: 'weather' }],
398
- graphTools: [],
399
- agentId:
400
- metadata?.langgraph_node === 'agent_2' ? 'agent_2' : 'agent_1',
401
- }) as unknown as AgentContext
451
+ (metadata?: Record<string, unknown>): AgentContext =>
452
+ ({
453
+ provider: Providers.ANTHROPIC,
454
+ reasoningKey: 'reasoning',
455
+ toolDefinitions: [{ name: 'weather' }],
456
+ graphTools: [],
457
+ agentId:
458
+ metadata?.langgraph_node === 'agent_2' ? 'agent_2' : 'agent_1',
459
+ }) as unknown as AgentContext
402
460
  ),
403
461
  });
404
462
  const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
405
- jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
406
- async (event, data): Promise<void> => {
463
+ jest
464
+ .spyOn(events, 'safeDispatchCustomEvent')
465
+ .mockImplementation(async (event, data): Promise<void> => {
407
466
  if (event !== GraphEvents.ON_TOOL_EXECUTE) {
408
467
  return;
409
468
  }
@@ -416,8 +475,7 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
416
475
  content: 'ok',
417
476
  }))
418
477
  );
419
- }
420
- );
478
+ });
421
479
 
422
480
  const handler = new ChatModelStreamHandler();
423
481
  await handler.handle(
@@ -463,25 +521,27 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
463
521
  ]);
464
522
  expect(usageByAgent.get('agent_1')?.get('weather')).toBe(1);
465
523
  expect(usageByAgent.get('agent_2')?.get('weather')).toBe(1);
466
- expect(graph.eagerEventToolExecutions.get('call_agent_1_weather')?.request.turn)
467
- .toBe(0);
468
- expect(graph.eagerEventToolExecutions.get('call_agent_2_weather')?.request.turn)
469
- .toBe(0);
524
+ expect(
525
+ graph.eagerEventToolExecutions.get('call_agent_1_weather')?.request.turn
526
+ ).toBe(0);
527
+ expect(
528
+ graph.eagerEventToolExecutions.get('call_agent_2_weather')?.request.turn
529
+ ).toBe(0);
470
530
  });
471
531
 
472
532
  it('skips eager for the whole batch if any call is not request-plannable', async () => {
473
533
  const graph = createGraph();
474
534
  const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
475
- jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
476
- async (event, data): Promise<void> => {
535
+ jest
536
+ .spyOn(events, 'safeDispatchCustomEvent')
537
+ .mockImplementation(async (event, data): Promise<void> => {
477
538
  if (event !== GraphEvents.ON_TOOL_EXECUTE) {
478
539
  return;
479
540
  }
480
541
  const batch = data as t.ToolExecuteBatchRequest;
481
542
  toolExecuteCalls.push(batch);
482
543
  batch.resolve([]);
483
- }
484
- );
544
+ });
485
545
 
486
546
  await new ChatModelStreamHandler().handle(
487
547
  GraphEvents.CHAT_MODEL_STREAM,
@@ -515,8 +575,9 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
515
575
  it('records complete chunk-only tool calls after creating a tool step', async () => {
516
576
  const graph = createGraph();
517
577
  const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
518
- jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
519
- async (event, data): Promise<void> => {
578
+ jest
579
+ .spyOn(events, 'safeDispatchCustomEvent')
580
+ .mockImplementation(async (event, data): Promise<void> => {
520
581
  if (event !== GraphEvents.ON_TOOL_EXECUTE) {
521
582
  return;
522
583
  }
@@ -529,8 +590,7 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
529
590
  content: 'sunny',
530
591
  },
531
592
  ]);
532
- }
533
- );
593
+ });
534
594
 
535
595
  await new ChatModelStreamHandler().handle(
536
596
  GraphEvents.CHAT_MODEL_STREAM,
@@ -554,8 +614,7 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
554
614
  expect(toolExecuteCalls).toHaveLength(0);
555
615
  expect(graph.toolCallStepIds.has('call_weather')).toBe(true);
556
616
  expect(
557
- graph.eagerEventToolCallChunks.get(chunkStateKey('step-key', 0))
558
- ?.argsText
617
+ graph.eagerEventToolCallChunks.get(chunkStateKey('step-key', 0))?.argsText
559
618
  ).toBe('{"city":"NYC"}');
560
619
  });
561
620
 
@@ -572,8 +631,9 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
572
631
  ) as unknown as StandardGraph['getAgentContext'],
573
632
  });
574
633
  const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
575
- jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
576
- async (event, data): Promise<void> => {
634
+ jest
635
+ .spyOn(events, 'safeDispatchCustomEvent')
636
+ .mockImplementation(async (event, data): Promise<void> => {
577
637
  if (event !== GraphEvents.ON_TOOL_EXECUTE) {
578
638
  return;
579
639
  }
@@ -586,8 +646,7 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
586
646
  content: 'sunny',
587
647
  },
588
648
  ]);
589
- }
590
- );
649
+ });
591
650
 
592
651
  const handler = new ChatModelStreamHandler();
593
652
  const metadata = { langgraph_node: 'agent' };
@@ -666,9 +725,9 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
666
725
  stepId: expect.stringMatching(/^step_/),
667
726
  turn: 0,
668
727
  });
669
- expect(graph.eagerEventToolCallChunks.has(chunkStateKey('step-key', 0))).toBe(
670
- false
671
- );
728
+ expect(
729
+ graph.eagerEventToolCallChunks.has(chunkStateKey('step-key', 0))
730
+ ).toBe(false);
672
731
  });
673
732
 
674
733
  it('keeps OpenAI Chat Completions streamed chunks on the final tool_calls path', async () => {
@@ -684,8 +743,9 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
684
743
  ) as unknown as StandardGraph['getAgentContext'],
685
744
  });
686
745
  const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
687
- jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
688
- async (event, data): Promise<void> => {
746
+ jest
747
+ .spyOn(events, 'safeDispatchCustomEvent')
748
+ .mockImplementation(async (event, data): Promise<void> => {
689
749
  if (event !== GraphEvents.ON_TOOL_EXECUTE) {
690
750
  return;
691
751
  }
@@ -698,8 +758,7 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
698
758
  content: `ok ${call.name}`,
699
759
  }))
700
760
  );
701
- }
702
- );
761
+ });
703
762
 
704
763
  const handler = new ChatModelStreamHandler();
705
764
  const metadata = { langgraph_node: 'agent' };
@@ -786,8 +845,9 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
786
845
  it('prestarts final tool calls even when the final chunk also has tool-call chunks', async () => {
787
846
  const graph = createGraph();
788
847
  const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
789
- jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
790
- async (event, data): Promise<void> => {
848
+ jest
849
+ .spyOn(events, 'safeDispatchCustomEvent')
850
+ .mockImplementation(async (event, data): Promise<void> => {
791
851
  if (event !== GraphEvents.ON_TOOL_EXECUTE) {
792
852
  return;
793
853
  }
@@ -800,8 +860,7 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
800
860
  content: 'sunny',
801
861
  },
802
862
  ]);
803
- }
804
- );
863
+ });
805
864
 
806
865
  await new ChatModelStreamHandler().handle(
807
866
  GraphEvents.CHAT_MODEL_STREAM,
@@ -843,8 +902,9 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
843
902
  it('waits for final tool calls before prestarting streamed chunk calls', async () => {
844
903
  const graph = createGraph();
845
904
  const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
846
- jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
847
- async (event, data): Promise<void> => {
905
+ jest
906
+ .spyOn(events, 'safeDispatchCustomEvent')
907
+ .mockImplementation(async (event, data): Promise<void> => {
848
908
  if (event !== GraphEvents.ON_TOOL_EXECUTE) {
849
909
  return;
850
910
  }
@@ -857,8 +917,7 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
857
917
  content: 'sunny',
858
918
  },
859
919
  ]);
860
- }
861
- );
920
+ });
862
921
 
863
922
  const handler = new ChatModelStreamHandler();
864
923
 
@@ -1033,8 +1092,9 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
1033
1092
  it('preserves repeated adjacent argument deltas', async () => {
1034
1093
  const graph = createGraph();
1035
1094
  const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
1036
- jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
1037
- async (event, data): Promise<void> => {
1095
+ jest
1096
+ .spyOn(events, 'safeDispatchCustomEvent')
1097
+ .mockImplementation(async (event, data): Promise<void> => {
1038
1098
  if (event !== GraphEvents.ON_TOOL_EXECUTE) {
1039
1099
  return;
1040
1100
  }
@@ -1047,8 +1107,7 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
1047
1107
  content: 'ok',
1048
1108
  },
1049
1109
  ]);
1050
- }
1051
- );
1110
+ });
1052
1111
 
1053
1112
  const handler = new ChatModelStreamHandler();
1054
1113
  const metadata = { langgraph_node: 'agent' };
@@ -1127,8 +1186,7 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
1127
1186
 
1128
1187
  expect(toolExecuteCalls).toHaveLength(0);
1129
1188
  expect(
1130
- graph.eagerEventToolCallChunks.get(chunkStateKey('step-key', 0))
1131
- ?.argsText
1189
+ graph.eagerEventToolCallChunks.get(chunkStateKey('step-key', 0))?.argsText
1132
1190
  ).toBe('{"word":"book"}');
1133
1191
 
1134
1192
  await handler.handle(
@@ -1202,8 +1260,7 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
1202
1260
  );
1203
1261
 
1204
1262
  expect(
1205
- graph.eagerEventToolCallChunks.get(chunkStateKey('step-key', 0))
1206
- ?.argsText
1263
+ graph.eagerEventToolCallChunks.get(chunkStateKey('step-key', 0))?.argsText
1207
1264
  ).toBe('oo');
1208
1265
  });
1209
1266
 
@@ -1249,16 +1306,16 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
1249
1306
  );
1250
1307
 
1251
1308
  expect(
1252
- graph.eagerEventToolCallChunks.get(chunkStateKey('step-key', 0))
1253
- ?.argsText
1309
+ graph.eagerEventToolCallChunks.get(chunkStateKey('step-key', 0))?.argsText
1254
1310
  ).toBe('{"titl');
1255
1311
  });
1256
1312
 
1257
1313
  it('does not prestart from cumulative streamed args before final tool calls', async () => {
1258
1314
  const graph = createGraph();
1259
1315
  const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
1260
- jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
1261
- async (event, data): Promise<void> => {
1316
+ jest
1317
+ .spyOn(events, 'safeDispatchCustomEvent')
1318
+ .mockImplementation(async (event, data): Promise<void> => {
1262
1319
  if (event !== GraphEvents.ON_TOOL_EXECUTE) {
1263
1320
  return;
1264
1321
  }
@@ -1271,8 +1328,7 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
1271
1328
  content: 'sunny',
1272
1329
  },
1273
1330
  ]);
1274
- }
1275
- );
1331
+ });
1276
1332
 
1277
1333
  const handler = new ChatModelStreamHandler();
1278
1334
  const metadata = { langgraph_node: 'agent' };
@@ -1353,8 +1409,7 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
1353
1409
 
1354
1410
  expect(toolExecuteCalls).toHaveLength(0);
1355
1411
  expect(
1356
- graph.eagerEventToolCallChunks.get(chunkStateKey('step-key', 0))
1357
- ?.argsText
1412
+ graph.eagerEventToolCallChunks.get(chunkStateKey('step-key', 0))?.argsText
1358
1413
  ).toBe('{"city":"NYC","unit":"C"}');
1359
1414
 
1360
1415
  await handler.handle(
@@ -1445,8 +1500,7 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
1445
1500
  );
1446
1501
 
1447
1502
  expect(
1448
- graph.eagerEventToolCallChunks.get(chunkStateKey('step-key', 0))
1449
- ?.argsText
1503
+ graph.eagerEventToolCallChunks.get(chunkStateKey('step-key', 0))?.argsText
1450
1504
  ).toBe('{"title":"alpha","city":"NYC"}');
1451
1505
  });
1452
1506
 
@@ -1504,8 +1558,7 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
1504
1558
  );
1505
1559
 
1506
1560
  expect(
1507
- graph.eagerEventToolCallChunks.get(chunkStateKey('step-key', 0))
1508
- ?.argsText
1561
+ graph.eagerEventToolCallChunks.get(chunkStateKey('step-key', 0))?.argsText
1509
1562
  ).toBe('{"word":"book"}');
1510
1563
  });
1511
1564
 
@@ -1590,8 +1643,7 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
1590
1643
  );
1591
1644
 
1592
1645
  expect(
1593
- graph.eagerEventToolCallChunks.get(chunkStateKey('step-key', 0))
1594
- ?.argsText
1646
+ graph.eagerEventToolCallChunks.get(chunkStateKey('step-key', 0))?.argsText
1595
1647
  ).toBe('{"city":"NYC"}');
1596
1648
  });
1597
1649
 
@@ -1599,8 +1651,9 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
1599
1651
  const graphA = createGraph();
1600
1652
  const graphB = createGraph();
1601
1653
  const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
1602
- jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
1603
- async (event, data): Promise<void> => {
1654
+ jest
1655
+ .spyOn(events, 'safeDispatchCustomEvent')
1656
+ .mockImplementation(async (event, data): Promise<void> => {
1604
1657
  if (event !== GraphEvents.ON_TOOL_EXECUTE) {
1605
1658
  return;
1606
1659
  }
@@ -1613,8 +1666,7 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
1613
1666
  content: `${request.id} result`,
1614
1667
  }))
1615
1668
  );
1616
- }
1617
- );
1669
+ });
1618
1670
 
1619
1671
  const handler = new ChatModelStreamHandler();
1620
1672
  const sharedChunk = {
@@ -1660,8 +1712,9 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
1660
1712
  ) as unknown as StandardGraph['getAgentContext'],
1661
1713
  });
1662
1714
  const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
1663
- jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
1664
- async (event, data): Promise<void> => {
1715
+ jest
1716
+ .spyOn(events, 'safeDispatchCustomEvent')
1717
+ .mockImplementation(async (event, data): Promise<void> => {
1665
1718
  if (event !== GraphEvents.ON_TOOL_EXECUTE) {
1666
1719
  return;
1667
1720
  }
@@ -1674,8 +1727,7 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
1674
1727
  content: `ok ${call.name}`,
1675
1728
  }))
1676
1729
  );
1677
- }
1678
- );
1730
+ });
1679
1731
 
1680
1732
  const handler = new ChatModelStreamHandler();
1681
1733
  const metadata = { langgraph_node: 'agent' };
@@ -1732,9 +1784,9 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
1732
1784
  ]);
1733
1785
  expect(graph.eagerEventToolExecutions.has('call_weather')).toBe(true);
1734
1786
  expect(graph.eagerEventToolExecutions.has('call_stock')).toBe(false);
1735
- expect(graph.eagerEventToolCallChunks.has(chunkStateKey('step-key', 0))).toBe(
1736
- false
1737
- );
1787
+ expect(
1788
+ graph.eagerEventToolCallChunks.has(chunkStateKey('step-key', 0))
1789
+ ).toBe(false);
1738
1790
  expect(
1739
1791
  graph.eagerEventToolCallChunks.get(chunkStateKey('step-key', 1))?.argsText
1740
1792
  ).toBe('{"ticker":"C');
@@ -1793,7 +1845,7 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
1793
1845
  expect(graph.eagerEventToolCallChunks.size).toBe(0);
1794
1846
  });
1795
1847
 
1796
- it('does not seal a streamed tool when the same chunk also carries its own index', async () => {
1848
+ it('emits a completed event when an eager streamed tool result settles', async () => {
1797
1849
  const graph = createGraph({
1798
1850
  getAgentContext: jest.fn(
1799
1851
  (): Partial<AgentContext> => ({
@@ -1805,14 +1857,18 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
1805
1857
  })
1806
1858
  ) as unknown as StandardGraph['getAgentContext'],
1807
1859
  });
1808
- const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
1809
- jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
1810
- async (event, data): Promise<void> => {
1860
+ const completedEvents: Array<{ result: t.ToolEndEvent }> = [];
1861
+ jest
1862
+ .spyOn(events, 'safeDispatchCustomEvent')
1863
+ .mockImplementation(async (event, data): Promise<void> => {
1864
+ if (event === GraphEvents.ON_RUN_STEP_COMPLETED) {
1865
+ completedEvents.push(data as { result: t.ToolEndEvent });
1866
+ return;
1867
+ }
1811
1868
  if (event !== GraphEvents.ON_TOOL_EXECUTE) {
1812
1869
  return;
1813
1870
  }
1814
1871
  const batch = data as t.ToolExecuteBatchRequest;
1815
- toolExecuteCalls.push(batch);
1816
1872
  batch.resolve(
1817
1873
  batch.toolCalls.map((call) => ({
1818
1874
  toolCallId: call.id,
@@ -1820,8 +1876,7 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
1820
1876
  content: `ok ${call.name}`,
1821
1877
  }))
1822
1878
  );
1823
- }
1824
- );
1879
+ });
1825
1880
 
1826
1881
  const handler = new ChatModelStreamHandler();
1827
1882
  const metadata = { langgraph_node: 'agent' };
@@ -1851,10 +1906,6 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
1851
1906
  chunk: {
1852
1907
  content: '',
1853
1908
  tool_call_chunks: [
1854
- {
1855
- args: '',
1856
- index: 0,
1857
- },
1858
1909
  {
1859
1910
  id: 'call_stock',
1860
1911
  name: 'stock',
@@ -1868,52 +1919,51 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
1868
1919
  graph
1869
1920
  );
1870
1921
 
1871
- expect(toolExecuteCalls).toHaveLength(0);
1922
+ await graph.eagerEventToolExecutions.get('call_weather')?.promise;
1872
1923
 
1873
- await handler.handle(
1874
- GraphEvents.CHAT_MODEL_STREAM,
1875
- {
1876
- chunk: {
1877
- content: '',
1878
- tool_call_chunks: [
1879
- {
1880
- args: 'H"}',
1881
- index: 1,
1882
- },
1883
- ],
1884
- } as unknown as t.StreamChunk,
1924
+ expect(completedEvents).toHaveLength(1);
1925
+ expect(completedEvents[0].result).toMatchObject({
1926
+ id: expect.stringMatching(/^step_/),
1927
+ type: 'tool_call',
1928
+ eager: true,
1929
+ tool_call: {
1930
+ id: 'call_weather',
1931
+ name: 'weather',
1932
+ args: '{"city":"NYC"}',
1933
+ output: 'ok weather',
1934
+ progress: 1,
1885
1935
  },
1886
- metadata,
1887
- graph
1888
- );
1889
-
1890
- expect(toolExecuteCalls).toHaveLength(1);
1891
- expect(toolExecuteCalls[0].toolCalls[0]).toMatchObject({
1892
- id: 'call_weather',
1893
- name: 'weather',
1894
- args: { city: 'NYC' },
1895
1936
  });
1937
+ expect(
1938
+ graph.eagerEventToolExecutions.get('call_weather')?.completionDispatched
1939
+ ).toBe(true);
1896
1940
  });
1897
1941
 
1898
- it('preserves same-tool turns across per-call streamed eager starts', async () => {
1899
- const graph = createGraph();
1900
- const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
1901
- jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
1902
- async (event, data): Promise<void> => {
1903
- if (event !== GraphEvents.ON_TOOL_EXECUTE) {
1942
+ it('does not emit a stale eager completion after the active execution is invalidated', async () => {
1943
+ const graph = createGraph({
1944
+ getAgentContext: jest.fn(
1945
+ (): Partial<AgentContext> => ({
1946
+ provider: Providers.ANTHROPIC,
1947
+ reasoningKey: 'reasoning',
1948
+ toolDefinitions: [{ name: 'weather' }, { name: 'stock' }],
1949
+ graphTools: [],
1950
+ agentId: 'agent_1',
1951
+ })
1952
+ ) as unknown as StandardGraph['getAgentContext'],
1953
+ });
1954
+ const completedEvents: Array<{ result: t.ToolEndEvent }> = [];
1955
+ let pendingBatch: t.ToolExecuteBatchRequest | undefined;
1956
+ jest
1957
+ .spyOn(events, 'safeDispatchCustomEvent')
1958
+ .mockImplementation(async (event, data): Promise<void> => {
1959
+ if (event === GraphEvents.ON_RUN_STEP_COMPLETED) {
1960
+ completedEvents.push(data as { result: t.ToolEndEvent });
1904
1961
  return;
1905
1962
  }
1906
- const batch = data as t.ToolExecuteBatchRequest;
1907
- toolExecuteCalls.push(batch);
1908
- batch.resolve(
1909
- batch.toolCalls.map((call) => ({
1910
- toolCallId: call.id,
1911
- status: 'success',
1912
- content: `ok ${call.args.city}`,
1913
- }))
1914
- );
1915
- }
1916
- );
1963
+ if (event === GraphEvents.ON_TOOL_EXECUTE) {
1964
+ pendingBatch = data as t.ToolExecuteBatchRequest;
1965
+ }
1966
+ });
1917
1967
 
1918
1968
  const handler = new ChatModelStreamHandler();
1919
1969
  const metadata = { langgraph_node: 'agent' };
@@ -1925,7 +1975,7 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
1925
1975
  content: '',
1926
1976
  tool_call_chunks: [
1927
1977
  {
1928
- id: 'call_weather_1',
1978
+ id: 'call_weather',
1929
1979
  name: 'weather',
1930
1980
  args: '{"city":"NYC"}',
1931
1981
  index: 0,
@@ -1936,7 +1986,6 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
1936
1986
  metadata,
1937
1987
  graph
1938
1988
  );
1939
-
1940
1989
  await handler.handle(
1941
1990
  GraphEvents.CHAT_MODEL_STREAM,
1942
1991
  {
@@ -1944,9 +1993,9 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
1944
1993
  content: '',
1945
1994
  tool_call_chunks: [
1946
1995
  {
1947
- id: 'call_weather_2',
1948
- name: 'weather',
1949
- args: '{"city":"B',
1996
+ id: 'call_stock',
1997
+ name: 'stock',
1998
+ args: '{"ticker":"C',
1950
1999
  index: 1,
1951
2000
  },
1952
2001
  ],
@@ -1956,90 +2005,56 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
1956
2005
  graph
1957
2006
  );
1958
2007
 
1959
- expect(toolExecuteCalls).toHaveLength(1);
1960
- expect(toolExecuteCalls[0].toolCalls[0]).toMatchObject({
1961
- id: 'call_weather_1',
1962
- name: 'weather',
1963
- args: { city: 'NYC' },
1964
- turn: 0,
1965
- });
1966
-
1967
- await handler.handle(
1968
- GraphEvents.CHAT_MODEL_STREAM,
1969
- {
1970
- chunk: {
1971
- content: '',
1972
- tool_call_chunks: [
1973
- {
1974
- args: 'oston"}',
1975
- index: 1,
1976
- },
1977
- ],
1978
- } as unknown as t.StreamChunk,
1979
- },
1980
- metadata,
1981
- graph
1982
- );
2008
+ const staleRecord = graph.eagerEventToolExecutions.get('call_weather');
2009
+ expect(staleRecord).toBeDefined();
2010
+ expect(pendingBatch?.toolCalls).toHaveLength(1);
1983
2011
 
1984
- await handler.handle(
1985
- GraphEvents.CHAT_MODEL_STREAM,
2012
+ graph.eagerEventToolExecutions.delete('call_weather');
2013
+ pendingBatch?.resolve([
1986
2014
  {
1987
- chunk: {
1988
- content: '',
1989
- tool_calls: [
1990
- {
1991
- id: 'call_weather_1',
1992
- name: 'weather',
1993
- args: { city: 'NYC' },
1994
- },
1995
- {
1996
- id: 'call_weather_2',
1997
- name: 'weather',
1998
- args: { city: 'Boston' },
1999
- },
2000
- ],
2001
- response_metadata: finalToolCallResponseMetadata,
2002
- } as unknown as t.StreamChunk,
2015
+ toolCallId: 'call_weather',
2016
+ status: 'success',
2017
+ content: 'stale weather',
2003
2018
  },
2004
- metadata,
2005
- graph
2006
- );
2019
+ ]);
2020
+ await staleRecord?.promise;
2007
2021
 
2008
- expect(toolExecuteCalls).toHaveLength(2);
2009
- expect(toolExecuteCalls[1].toolCalls[0]).toMatchObject({
2010
- id: 'call_weather_2',
2011
- name: 'weather',
2012
- args: { city: 'Boston' },
2013
- turn: 1,
2014
- });
2015
- expect(graph.eagerEventToolUsageCount.get('weather')).toBe(2);
2022
+ expect(completedEvents).toHaveLength(0);
2023
+ expect(staleRecord?.completionDispatched).toBeUndefined();
2016
2024
  });
2017
2025
 
2018
- it('scopes streamed chunk accumulation by step key', async () => {
2026
+ it('does not mark eager completion dispatched when event delivery is swallowed', async () => {
2019
2027
  const graph = createGraph({
2020
- getStepKey: jest.fn((metadata?: Record<string, unknown>) =>
2021
- String(metadata?.langgraph_node ?? 'step-key')
2022
- ),
2028
+ getAgentContext: jest.fn(
2029
+ (): Partial<AgentContext> => ({
2030
+ provider: Providers.ANTHROPIC,
2031
+ reasoningKey: 'reasoning',
2032
+ toolDefinitions: [{ name: 'weather' }, { name: 'stock' }],
2033
+ graphTools: [],
2034
+ agentId: 'agent_1',
2035
+ })
2036
+ ) as unknown as StandardGraph['getAgentContext'],
2023
2037
  });
2024
- const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
2025
- jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
2026
- async (event, data): Promise<void> => {
2027
- if (event !== GraphEvents.ON_TOOL_EXECUTE) {
2028
- return;
2038
+ jest
2039
+ .spyOn(events, 'safeDispatchCustomEvent')
2040
+ .mockImplementation(async (event, data): Promise<boolean | void> => {
2041
+ if (event === GraphEvents.ON_RUN_STEP_COMPLETED) {
2042
+ return false;
2029
2043
  }
2030
- const batch = data as t.ToolExecuteBatchRequest;
2031
- toolExecuteCalls.push(batch);
2032
- batch.resolve(
2033
- batch.toolCalls.map((call) => ({
2034
- toolCallId: call.id,
2035
- status: 'success',
2036
- content: `ok ${call.name}`,
2037
- }))
2038
- );
2039
- }
2040
- );
2044
+ if (event === GraphEvents.ON_TOOL_EXECUTE) {
2045
+ const batch = data as t.ToolExecuteBatchRequest;
2046
+ batch.resolve([
2047
+ {
2048
+ toolCallId: 'call_weather',
2049
+ status: 'success',
2050
+ content: 'weather result',
2051
+ },
2052
+ ]);
2053
+ }
2054
+ });
2041
2055
 
2042
2056
  const handler = new ChatModelStreamHandler();
2057
+ const metadata = { langgraph_node: 'agent' };
2043
2058
 
2044
2059
  await handler.handle(
2045
2060
  GraphEvents.CHAT_MODEL_STREAM,
@@ -2048,18 +2063,17 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
2048
2063
  content: '',
2049
2064
  tool_call_chunks: [
2050
2065
  {
2051
- id: 'call_agent_a',
2066
+ id: 'call_weather',
2052
2067
  name: 'weather',
2053
- args: '{"city":"N',
2068
+ args: '{"city":"NYC"}',
2054
2069
  index: 0,
2055
2070
  },
2056
2071
  ],
2057
2072
  } as unknown as t.StreamChunk,
2058
2073
  },
2059
- { langgraph_node: 'agent_a' },
2074
+ metadata,
2060
2075
  graph
2061
2076
  );
2062
-
2063
2077
  await handler.handle(
2064
2078
  GraphEvents.CHAT_MODEL_STREAM,
2065
2079
  {
@@ -2067,18 +2081,808 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
2067
2081
  content: '',
2068
2082
  tool_call_chunks: [
2069
2083
  {
2070
- id: 'call_agent_b',
2071
- name: 'weather',
2072
- args: '{"city":"S',
2073
- index: 0,
2084
+ id: 'call_stock',
2085
+ name: 'stock',
2086
+ args: '{"ticker":"C',
2087
+ index: 1,
2088
+ },
2089
+ ],
2090
+ } as unknown as t.StreamChunk,
2091
+ },
2092
+ metadata,
2093
+ graph
2094
+ );
2095
+
2096
+ const record = graph.eagerEventToolExecutions.get('call_weather');
2097
+ await record?.promise;
2098
+
2099
+ expect(record?.completionDispatched).toBeUndefined();
2100
+ });
2101
+
2102
+ it('does not overwrite a completed tool output with later streamed deltas', () => {
2103
+ const { contentParts, aggregateContent } = createContentAggregator();
2104
+
2105
+ aggregateContent({
2106
+ event: GraphEvents.ON_RUN_STEP,
2107
+ data: {
2108
+ id: 'step_weather',
2109
+ type: StepTypes.TOOL_CALLS,
2110
+ index: 0,
2111
+ stepDetails: {
2112
+ type: StepTypes.TOOL_CALLS,
2113
+ tool_calls: [
2114
+ {
2115
+ id: 'call_weather',
2116
+ name: 'weather',
2117
+ args: {},
2118
+ },
2119
+ ],
2120
+ },
2121
+ usage: null,
2122
+ } as t.RunStep,
2123
+ });
2124
+ aggregateContent({
2125
+ event: GraphEvents.ON_RUN_STEP_COMPLETED,
2126
+ data: {
2127
+ result: {
2128
+ id: 'step_weather',
2129
+ index: 0,
2130
+ type: 'tool_call',
2131
+ tool_call: {
2132
+ id: 'call_weather',
2133
+ name: 'weather',
2134
+ args: '{"city":"NYC"}',
2135
+ output: 'sunny',
2136
+ progress: 1,
2137
+ } as t.ProcessedToolCall,
2138
+ },
2139
+ } as { result: t.ToolEndEvent },
2140
+ });
2141
+ aggregateContent({
2142
+ event: GraphEvents.ON_RUN_STEP_DELTA,
2143
+ data: {
2144
+ id: 'step_weather',
2145
+ delta: {
2146
+ type: StepTypes.TOOL_CALLS,
2147
+ tool_calls: [
2148
+ {
2149
+ id: 'call_weather',
2150
+ name: 'weather',
2151
+ args: '{"city":"NYC revised"}',
2152
+ },
2153
+ ],
2154
+ },
2155
+ } as t.RunStepDeltaEvent,
2156
+ });
2157
+
2158
+ expect(contentParts[0]).toMatchObject({
2159
+ type: ContentTypes.TOOL_CALL,
2160
+ tool_call: {
2161
+ id: 'call_weather',
2162
+ name: 'weather',
2163
+ args: '{"city":"NYC"}',
2164
+ output: 'sunny',
2165
+ progress: 1,
2166
+ },
2167
+ });
2168
+ });
2169
+
2170
+ it('does not use next-index sealing for Moonshot streamed tool chunks', async () => {
2171
+ const graph = createGraph({
2172
+ getAgentContext: jest.fn(
2173
+ (): Partial<AgentContext> => ({
2174
+ provider: Providers.MOONSHOT,
2175
+ reasoningKey: 'reasoning',
2176
+ toolDefinitions: [{ name: 'weather' }, { name: 'stock' }],
2177
+ graphTools: [],
2178
+ agentId: 'agent_1',
2179
+ })
2180
+ ) as unknown as StandardGraph['getAgentContext'],
2181
+ });
2182
+ const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
2183
+ jest
2184
+ .spyOn(events, 'safeDispatchCustomEvent')
2185
+ .mockImplementation(async (event, data): Promise<void> => {
2186
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
2187
+ return;
2188
+ }
2189
+ const batch = data as t.ToolExecuteBatchRequest;
2190
+ toolExecuteCalls.push(batch);
2191
+ batch.resolve(
2192
+ batch.toolCalls.map((call) => ({
2193
+ toolCallId: call.id,
2194
+ status: 'success',
2195
+ content: `ok ${call.name}`,
2196
+ }))
2197
+ );
2198
+ });
2199
+
2200
+ const handler = new ChatModelStreamHandler();
2201
+ const metadata = { langgraph_node: 'agent' };
2202
+
2203
+ await handler.handle(
2204
+ GraphEvents.CHAT_MODEL_STREAM,
2205
+ {
2206
+ chunk: {
2207
+ content: '',
2208
+ tool_call_chunks: [
2209
+ {
2210
+ id: 'call_weather',
2211
+ name: 'weather',
2212
+ args: '{"city":"NYC"}',
2213
+ index: 0,
2214
+ },
2215
+ ],
2216
+ } as unknown as t.StreamChunk,
2217
+ },
2218
+ metadata,
2219
+ graph
2220
+ );
2221
+ await handler.handle(
2222
+ GraphEvents.CHAT_MODEL_STREAM,
2223
+ {
2224
+ chunk: {
2225
+ content: '',
2226
+ tool_call_chunks: [
2227
+ {
2228
+ id: 'call_stock',
2229
+ name: 'stock',
2230
+ args: '{"ticker":"C',
2231
+ index: 1,
2232
+ },
2233
+ ],
2234
+ } as unknown as t.StreamChunk,
2235
+ },
2236
+ metadata,
2237
+ graph
2238
+ );
2239
+
2240
+ expect(toolExecuteCalls).toHaveLength(0);
2241
+ expect(graph.eagerEventToolExecutions.size).toBe(0);
2242
+ expect(graph.eagerEventToolCallChunks.size).toBe(0);
2243
+
2244
+ await handler.handle(
2245
+ GraphEvents.CHAT_MODEL_STREAM,
2246
+ {
2247
+ chunk: {
2248
+ content: '',
2249
+ tool_calls: [
2250
+ {
2251
+ id: 'call_weather',
2252
+ name: 'weather',
2253
+ args: { city: 'NYC revised' },
2254
+ },
2255
+ {
2256
+ id: 'call_stock',
2257
+ name: 'stock',
2258
+ args: { ticker: 'CH' },
2259
+ },
2260
+ ],
2261
+ response_metadata: finalToolCallResponseMetadata,
2262
+ } as unknown as t.StreamChunk,
2263
+ },
2264
+ metadata,
2265
+ graph
2266
+ );
2267
+
2268
+ expect(toolExecuteCalls).toHaveLength(1);
2269
+ expect(toolExecuteCalls[0].toolCalls).toEqual([
2270
+ expect.objectContaining({
2271
+ id: 'call_weather',
2272
+ name: 'weather',
2273
+ args: { city: 'NYC revised' },
2274
+ }),
2275
+ expect.objectContaining({
2276
+ id: 'call_stock',
2277
+ name: 'stock',
2278
+ args: { ticker: 'CH' },
2279
+ }),
2280
+ ]);
2281
+ });
2282
+
2283
+ it('does not seal a streamed tool when the same chunk also carries its own index', async () => {
2284
+ const graph = createGraph({
2285
+ getAgentContext: jest.fn(
2286
+ (): Partial<AgentContext> => ({
2287
+ provider: Providers.ANTHROPIC,
2288
+ reasoningKey: 'reasoning',
2289
+ toolDefinitions: [{ name: 'weather' }, { name: 'stock' }],
2290
+ graphTools: [],
2291
+ agentId: 'agent_1',
2292
+ })
2293
+ ) as unknown as StandardGraph['getAgentContext'],
2294
+ });
2295
+ const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
2296
+ jest
2297
+ .spyOn(events, 'safeDispatchCustomEvent')
2298
+ .mockImplementation(async (event, data): Promise<void> => {
2299
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
2300
+ return;
2301
+ }
2302
+ const batch = data as t.ToolExecuteBatchRequest;
2303
+ toolExecuteCalls.push(batch);
2304
+ batch.resolve(
2305
+ batch.toolCalls.map((call) => ({
2306
+ toolCallId: call.id,
2307
+ status: 'success',
2308
+ content: `ok ${call.name}`,
2309
+ }))
2310
+ );
2311
+ });
2312
+
2313
+ const handler = new ChatModelStreamHandler();
2314
+ const metadata = { langgraph_node: 'agent' };
2315
+
2316
+ await handler.handle(
2317
+ GraphEvents.CHAT_MODEL_STREAM,
2318
+ {
2319
+ chunk: {
2320
+ content: '',
2321
+ tool_call_chunks: [
2322
+ {
2323
+ id: 'call_weather',
2324
+ name: 'weather',
2325
+ args: '{"city":"NYC"}',
2326
+ index: 0,
2327
+ },
2328
+ ],
2329
+ } as unknown as t.StreamChunk,
2330
+ },
2331
+ metadata,
2332
+ graph
2333
+ );
2334
+
2335
+ await handler.handle(
2336
+ GraphEvents.CHAT_MODEL_STREAM,
2337
+ {
2338
+ chunk: {
2339
+ content: '',
2340
+ tool_call_chunks: [
2341
+ {
2342
+ args: '',
2343
+ index: 0,
2344
+ },
2345
+ {
2346
+ id: 'call_stock',
2347
+ name: 'stock',
2348
+ args: '{"ticker":"C',
2349
+ index: 1,
2350
+ },
2351
+ ],
2352
+ } as unknown as t.StreamChunk,
2353
+ },
2354
+ metadata,
2355
+ graph
2356
+ );
2357
+
2358
+ expect(toolExecuteCalls).toHaveLength(0);
2359
+
2360
+ await handler.handle(
2361
+ GraphEvents.CHAT_MODEL_STREAM,
2362
+ {
2363
+ chunk: {
2364
+ content: '',
2365
+ tool_call_chunks: [
2366
+ {
2367
+ args: 'H"}',
2368
+ index: 1,
2369
+ },
2370
+ ],
2371
+ } as unknown as t.StreamChunk,
2372
+ },
2373
+ metadata,
2374
+ graph
2375
+ );
2376
+
2377
+ expect(toolExecuteCalls).toHaveLength(1);
2378
+ expect(toolExecuteCalls[0].toolCalls[0]).toMatchObject({
2379
+ id: 'call_weather',
2380
+ name: 'weather',
2381
+ args: { city: 'NYC' },
2382
+ });
2383
+ });
2384
+
2385
+ it('preserves same-tool turns across per-call streamed eager starts', async () => {
2386
+ const graph = createGraph();
2387
+ const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
2388
+ jest
2389
+ .spyOn(events, 'safeDispatchCustomEvent')
2390
+ .mockImplementation(async (event, data): Promise<void> => {
2391
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
2392
+ return;
2393
+ }
2394
+ const batch = data as t.ToolExecuteBatchRequest;
2395
+ toolExecuteCalls.push(batch);
2396
+ batch.resolve(
2397
+ batch.toolCalls.map((call) => ({
2398
+ toolCallId: call.id,
2399
+ status: 'success',
2400
+ content: `ok ${call.args.city}`,
2401
+ }))
2402
+ );
2403
+ });
2404
+
2405
+ const handler = new ChatModelStreamHandler();
2406
+ const metadata = { langgraph_node: 'agent' };
2407
+
2408
+ await handler.handle(
2409
+ GraphEvents.CHAT_MODEL_STREAM,
2410
+ {
2411
+ chunk: {
2412
+ content: '',
2413
+ tool_call_chunks: [
2414
+ {
2415
+ id: 'call_weather_1',
2416
+ name: 'weather',
2417
+ args: '{"city":"NYC"}',
2418
+ index: 0,
2419
+ },
2420
+ ],
2421
+ } as unknown as t.StreamChunk,
2422
+ },
2423
+ metadata,
2424
+ graph
2425
+ );
2426
+
2427
+ await handler.handle(
2428
+ GraphEvents.CHAT_MODEL_STREAM,
2429
+ {
2430
+ chunk: {
2431
+ content: '',
2432
+ tool_call_chunks: [
2433
+ {
2434
+ id: 'call_weather_2',
2435
+ name: 'weather',
2436
+ args: '{"city":"B',
2437
+ index: 1,
2438
+ },
2439
+ ],
2440
+ } as unknown as t.StreamChunk,
2441
+ },
2442
+ metadata,
2443
+ graph
2444
+ );
2445
+
2446
+ expect(toolExecuteCalls).toHaveLength(1);
2447
+ expect(toolExecuteCalls[0].toolCalls[0]).toMatchObject({
2448
+ id: 'call_weather_1',
2449
+ name: 'weather',
2450
+ args: { city: 'NYC' },
2451
+ turn: 0,
2452
+ });
2453
+
2454
+ await handler.handle(
2455
+ GraphEvents.CHAT_MODEL_STREAM,
2456
+ {
2457
+ chunk: {
2458
+ content: '',
2459
+ tool_call_chunks: [
2460
+ {
2461
+ args: 'oston"}',
2462
+ index: 1,
2463
+ },
2464
+ ],
2465
+ } as unknown as t.StreamChunk,
2466
+ },
2467
+ metadata,
2468
+ graph
2469
+ );
2470
+
2471
+ await handler.handle(
2472
+ GraphEvents.CHAT_MODEL_STREAM,
2473
+ {
2474
+ chunk: {
2475
+ content: '',
2476
+ tool_calls: [
2477
+ {
2478
+ id: 'call_weather_1',
2479
+ name: 'weather',
2480
+ args: { city: 'NYC' },
2481
+ },
2482
+ {
2483
+ id: 'call_weather_2',
2484
+ name: 'weather',
2485
+ args: { city: 'Boston' },
2486
+ },
2487
+ ],
2488
+ response_metadata: finalToolCallResponseMetadata,
2489
+ } as unknown as t.StreamChunk,
2490
+ },
2491
+ metadata,
2492
+ graph
2493
+ );
2494
+
2495
+ expect(toolExecuteCalls).toHaveLength(2);
2496
+ expect(toolExecuteCalls[1].toolCalls[0]).toMatchObject({
2497
+ id: 'call_weather_2',
2498
+ name: 'weather',
2499
+ args: { city: 'Boston' },
2500
+ turn: 1,
2501
+ });
2502
+ expect(graph.eagerEventToolUsageCount.get('weather')).toBe(2);
2503
+ });
2504
+
2505
+ it('scopes streamed chunk accumulation by step key', async () => {
2506
+ const graph = createGraph({
2507
+ getStepKey: jest.fn((metadata?: Record<string, unknown>) =>
2508
+ String(metadata?.langgraph_node ?? 'step-key')
2509
+ ),
2510
+ });
2511
+ const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
2512
+ jest
2513
+ .spyOn(events, 'safeDispatchCustomEvent')
2514
+ .mockImplementation(async (event, data): Promise<void> => {
2515
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
2516
+ return;
2517
+ }
2518
+ const batch = data as t.ToolExecuteBatchRequest;
2519
+ toolExecuteCalls.push(batch);
2520
+ batch.resolve(
2521
+ batch.toolCalls.map((call) => ({
2522
+ toolCallId: call.id,
2523
+ status: 'success',
2524
+ content: `ok ${call.name}`,
2525
+ }))
2526
+ );
2527
+ });
2528
+
2529
+ const handler = new ChatModelStreamHandler();
2530
+
2531
+ await handler.handle(
2532
+ GraphEvents.CHAT_MODEL_STREAM,
2533
+ {
2534
+ chunk: {
2535
+ content: '',
2536
+ tool_call_chunks: [
2537
+ {
2538
+ id: 'call_agent_a',
2539
+ name: 'weather',
2540
+ args: '{"city":"N',
2541
+ index: 0,
2542
+ },
2543
+ ],
2544
+ } as unknown as t.StreamChunk,
2545
+ },
2546
+ { langgraph_node: 'agent_a' },
2547
+ graph
2548
+ );
2549
+
2550
+ await handler.handle(
2551
+ GraphEvents.CHAT_MODEL_STREAM,
2552
+ {
2553
+ chunk: {
2554
+ content: '',
2555
+ tool_call_chunks: [
2556
+ {
2557
+ id: 'call_agent_b',
2558
+ name: 'weather',
2559
+ args: '{"city":"S',
2560
+ index: 0,
2561
+ },
2562
+ ],
2563
+ } as unknown as t.StreamChunk,
2564
+ },
2565
+ { langgraph_node: 'agent_b' },
2566
+ graph
2567
+ );
2568
+
2569
+ await handler.handle(
2570
+ GraphEvents.CHAT_MODEL_STREAM,
2571
+ {
2572
+ chunk: {
2573
+ content: '',
2574
+ tool_call_chunks: [
2575
+ {
2576
+ args: 'F"}',
2577
+ index: 0,
2578
+ },
2579
+ ],
2580
+ } as unknown as t.StreamChunk,
2581
+ },
2582
+ { langgraph_node: 'agent_b' },
2583
+ graph
2584
+ );
2585
+
2586
+ expect(toolExecuteCalls).toHaveLength(0);
2587
+
2588
+ await handler.handle(
2589
+ GraphEvents.CHAT_MODEL_STREAM,
2590
+ {
2591
+ chunk: {
2592
+ content: '',
2593
+ tool_call_chunks: [
2594
+ {
2595
+ args: 'YC"}',
2596
+ index: 0,
2597
+ },
2598
+ ],
2599
+ } as unknown as t.StreamChunk,
2600
+ },
2601
+ { langgraph_node: 'agent_a' },
2602
+ graph
2603
+ );
2604
+
2605
+ expect(toolExecuteCalls).toHaveLength(0);
2606
+ expect(
2607
+ graph.eagerEventToolCallChunks.get(chunkStateKey('agent_a', 0))?.argsText
2608
+ ).toBe('{"city":"NYC"}');
2609
+ expect(
2610
+ graph.eagerEventToolCallChunks.get(chunkStateKey('agent_b', 0))?.argsText
2611
+ ).toBe('{"city":"SF"}');
2612
+
2613
+ await handler.handle(
2614
+ GraphEvents.CHAT_MODEL_STREAM,
2615
+ {
2616
+ chunk: {
2617
+ content: '',
2618
+ tool_calls: [
2619
+ {
2620
+ id: 'call_agent_b',
2621
+ name: 'weather',
2622
+ args: { city: 'SF' },
2074
2623
  },
2075
2624
  ],
2625
+ response_metadata: finalToolCallResponseMetadata,
2076
2626
  } as unknown as t.StreamChunk,
2077
2627
  },
2078
2628
  { langgraph_node: 'agent_b' },
2079
2629
  graph
2080
2630
  );
2081
2631
 
2632
+ expect(
2633
+ graph.eagerEventToolCallChunks.has(chunkStateKey('agent_b', 0))
2634
+ ).toBe(false);
2635
+ expect(
2636
+ graph.eagerEventToolCallChunks.get(chunkStateKey('agent_a', 0))?.argsText
2637
+ ).toBe('{"city":"NYC"}');
2638
+
2639
+ await handler.handle(
2640
+ GraphEvents.CHAT_MODEL_STREAM,
2641
+ {
2642
+ chunk: {
2643
+ content: '',
2644
+ tool_calls: [
2645
+ {
2646
+ id: 'call_agent_a',
2647
+ name: 'weather',
2648
+ args: { city: 'NYC' },
2649
+ },
2650
+ ],
2651
+ response_metadata: finalToolCallResponseMetadata,
2652
+ } as unknown as t.StreamChunk,
2653
+ },
2654
+ { langgraph_node: 'agent_a' },
2655
+ graph
2656
+ );
2657
+
2658
+ expect(toolExecuteCalls).toHaveLength(2);
2659
+ expect(toolExecuteCalls[0].toolCalls[0]).toMatchObject({
2660
+ id: 'call_agent_b',
2661
+ name: 'weather',
2662
+ args: { city: 'SF' },
2663
+ });
2664
+ expect(toolExecuteCalls[1].toolCalls[0]).toMatchObject({
2665
+ id: 'call_agent_a',
2666
+ name: 'weather',
2667
+ args: { city: 'NYC' },
2668
+ });
2669
+ expect(graph.eagerEventToolCallChunks.size).toBe(0);
2670
+ });
2671
+
2672
+ it('does not prestart when batch-sensitive hooks are configured', async () => {
2673
+ const graph = createGraph({
2674
+ hookRegistry: {} as StandardGraph['hookRegistry'],
2675
+ });
2676
+ const dispatchSpy = jest.spyOn(events, 'safeDispatchCustomEvent');
2677
+
2678
+ await new ChatModelStreamHandler().handle(
2679
+ GraphEvents.CHAT_MODEL_STREAM,
2680
+ {
2681
+ chunk: {
2682
+ content: '',
2683
+ tool_calls: [
2684
+ {
2685
+ id: 'call_weather',
2686
+ name: 'weather',
2687
+ args: { city: 'NYC' },
2688
+ },
2689
+ ],
2690
+ } as unknown as t.StreamChunk,
2691
+ },
2692
+ { langgraph_node: 'agent' },
2693
+ graph
2694
+ );
2695
+
2696
+ expect(dispatchSpy).not.toHaveBeenCalledWith(
2697
+ GraphEvents.ON_TOOL_EXECUTE,
2698
+ expect.anything(),
2699
+ expect.anything()
2700
+ );
2701
+ expect(graph.eagerEventToolExecutions.size).toBe(0);
2702
+ });
2703
+
2704
+ it('does not buffer streamed chunks when eager execution is disabled', async () => {
2705
+ const graph = createGraph({
2706
+ eagerEventToolExecution: { enabled: false },
2707
+ } as Partial<StandardGraph>);
2708
+ const dispatchSpy = jest.spyOn(events, 'safeDispatchCustomEvent');
2709
+
2710
+ await new ChatModelStreamHandler().handle(
2711
+ GraphEvents.CHAT_MODEL_STREAM,
2712
+ {
2713
+ chunk: {
2714
+ content: '',
2715
+ tool_call_chunks: [
2716
+ {
2717
+ id: 'call_weather',
2718
+ name: 'weather',
2719
+ args: '{"city":"NYC"}',
2720
+ index: 0,
2721
+ },
2722
+ ],
2723
+ } as unknown as t.StreamChunk,
2724
+ },
2725
+ { langgraph_node: 'agent' },
2726
+ graph
2727
+ );
2728
+
2729
+ expect(dispatchSpy).not.toHaveBeenCalledWith(
2730
+ GraphEvents.ON_TOOL_EXECUTE,
2731
+ expect.anything(),
2732
+ expect.anything()
2733
+ );
2734
+ expect(graph.eagerEventToolExecutions.size).toBe(0);
2735
+ expect(graph.eagerEventToolCallChunks.size).toBe(0);
2736
+ });
2737
+
2738
+ it('does not prestart local-engine direct coding tools', async () => {
2739
+ const graph = createGraph({
2740
+ toolExecution: {
2741
+ engine: 'local',
2742
+ } as StandardGraph['toolExecution'],
2743
+ getAgentContext: jest.fn(
2744
+ (): Partial<AgentContext> => ({
2745
+ provider: Providers.OPENAI,
2746
+ reasoningKey: 'reasoning_content',
2747
+ toolDefinitions: [{ name: Constants.EXECUTE_CODE }],
2748
+ graphTools: [],
2749
+ agentId: 'agent_1',
2750
+ })
2751
+ ) as unknown as StandardGraph['getAgentContext'],
2752
+ });
2753
+ const dispatchSpy = jest.spyOn(events, 'safeDispatchCustomEvent');
2754
+
2755
+ await new ChatModelStreamHandler().handle(
2756
+ GraphEvents.CHAT_MODEL_STREAM,
2757
+ {
2758
+ chunk: {
2759
+ content: '',
2760
+ tool_calls: [
2761
+ {
2762
+ id: 'call_code',
2763
+ name: Constants.EXECUTE_CODE,
2764
+ args: { code: 'print(1)' },
2765
+ },
2766
+ ],
2767
+ } as unknown as t.StreamChunk,
2768
+ },
2769
+ { langgraph_node: 'agent' },
2770
+ graph
2771
+ );
2772
+
2773
+ expect(dispatchSpy).not.toHaveBeenCalledWith(
2774
+ GraphEvents.ON_TOOL_EXECUTE,
2775
+ expect.anything(),
2776
+ expect.anything()
2777
+ );
2778
+ expect(graph.eagerEventToolExecutions.size).toBe(0);
2779
+ expect(graph.eagerEventToolCallChunks.size).toBe(0);
2780
+ });
2781
+
2782
+ it('does not prestart streamed local-engine direct coding tools', async () => {
2783
+ const graph = createGraph({
2784
+ toolExecution: {
2785
+ engine: 'local',
2786
+ } as StandardGraph['toolExecution'],
2787
+ getAgentContext: jest.fn(
2788
+ (): Partial<AgentContext> => ({
2789
+ provider: Providers.OPENAI,
2790
+ reasoningKey: 'reasoning_content',
2791
+ toolDefinitions: [
2792
+ { name: Constants.EXECUTE_CODE },
2793
+ { name: 'weather' },
2794
+ ],
2795
+ graphTools: [],
2796
+ agentId: 'agent_1',
2797
+ })
2798
+ ) as unknown as StandardGraph['getAgentContext'],
2799
+ });
2800
+ const dispatchSpy = jest.spyOn(events, 'safeDispatchCustomEvent');
2801
+ const handler = new ChatModelStreamHandler();
2802
+ const metadata = { langgraph_node: 'agent' };
2803
+
2804
+ await handler.handle(
2805
+ GraphEvents.CHAT_MODEL_STREAM,
2806
+ {
2807
+ chunk: {
2808
+ content: '',
2809
+ tool_call_chunks: [
2810
+ {
2811
+ id: 'call_weather',
2812
+ name: 'weather',
2813
+ args: '{"city":"NYC"}',
2814
+ index: 0,
2815
+ },
2816
+ ],
2817
+ } as unknown as t.StreamChunk,
2818
+ },
2819
+ metadata,
2820
+ graph
2821
+ );
2822
+ await handler.handle(
2823
+ GraphEvents.CHAT_MODEL_STREAM,
2824
+ {
2825
+ chunk: {
2826
+ content: '',
2827
+ tool_call_chunks: [
2828
+ {
2829
+ id: 'call_code',
2830
+ name: Constants.EXECUTE_CODE,
2831
+ args: '{"code":"print(1)"}',
2832
+ index: 1,
2833
+ },
2834
+ ],
2835
+ } as unknown as t.StreamChunk,
2836
+ },
2837
+ metadata,
2838
+ graph
2839
+ );
2840
+
2841
+ expect(dispatchSpy).not.toHaveBeenCalledWith(
2842
+ GraphEvents.ON_TOOL_EXECUTE,
2843
+ expect.anything(),
2844
+ expect.anything()
2845
+ );
2846
+ expect(graph.eagerEventToolExecutions.size).toBe(0);
2847
+ expect(graph.eagerEventToolCallChunks.size).toBe(0);
2848
+ });
2849
+
2850
+ it('prestarts streamed remote bash tools when the next Anthropic tool call begins', async () => {
2851
+ const graph = createGraph({
2852
+ getAgentContext: jest.fn(
2853
+ (): Partial<AgentContext> => ({
2854
+ provider: Providers.ANTHROPIC,
2855
+ reasoningKey: 'reasoning',
2856
+ toolDefinitions: [
2857
+ { name: Constants.BASH_TOOL },
2858
+ { name: Constants.READ_FILE },
2859
+ ],
2860
+ graphTools: [],
2861
+ agentId: 'agent_1',
2862
+ })
2863
+ ) as unknown as StandardGraph['getAgentContext'],
2864
+ });
2865
+ const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
2866
+ jest
2867
+ .spyOn(events, 'safeDispatchCustomEvent')
2868
+ .mockImplementation(async (event, data): Promise<void> => {
2869
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
2870
+ return;
2871
+ }
2872
+ const batch = data as t.ToolExecuteBatchRequest;
2873
+ toolExecuteCalls.push(batch);
2874
+ batch.resolve(
2875
+ batch.toolCalls.map((call) => ({
2876
+ toolCallId: call.id,
2877
+ status: 'success',
2878
+ content: `ok ${call.name}`,
2879
+ }))
2880
+ );
2881
+ });
2882
+
2883
+ const handler = new ChatModelStreamHandler();
2884
+ const metadata = { langgraph_node: 'agent' };
2885
+
2082
2886
  await handler.handle(
2083
2887
  GraphEvents.CHAT_MODEL_STREAM,
2084
2888
  {
@@ -2086,13 +2890,15 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
2086
2890
  content: '',
2087
2891
  tool_call_chunks: [
2088
2892
  {
2089
- args: 'F"}',
2090
- index: 0,
2893
+ id: 'toolu_env',
2894
+ name: Constants.BASH_TOOL,
2895
+ args: '{"command":"echo env"}',
2896
+ index: 2,
2091
2897
  },
2092
2898
  ],
2093
2899
  } as unknown as t.StreamChunk,
2094
2900
  },
2095
- { langgraph_node: 'agent_b' },
2901
+ metadata,
2096
2902
  graph
2097
2903
  );
2098
2904
 
@@ -2105,135 +2911,256 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
2105
2911
  content: '',
2106
2912
  tool_call_chunks: [
2107
2913
  {
2108
- args: 'YC"}',
2109
- index: 0,
2914
+ id: 'toolu_net',
2915
+ name: Constants.BASH_TOOL,
2916
+ args: '{"command":"echo net"}',
2917
+ index: 3,
2110
2918
  },
2111
2919
  ],
2112
2920
  } as unknown as t.StreamChunk,
2113
2921
  },
2114
- { langgraph_node: 'agent_a' },
2922
+ metadata,
2115
2923
  graph
2116
2924
  );
2117
2925
 
2118
- expect(toolExecuteCalls).toHaveLength(0);
2119
- expect(
2120
- graph.eagerEventToolCallChunks.get(chunkStateKey('agent_a', 0))?.argsText
2121
- ).toBe('{"city":"NYC"}');
2122
- expect(
2123
- graph.eagerEventToolCallChunks.get(chunkStateKey('agent_b', 0))?.argsText
2124
- ).toBe('{"city":"SF"}');
2926
+ expect(toolExecuteCalls).toHaveLength(1);
2927
+ expect(toolExecuteCalls[0].toolCalls).toEqual([
2928
+ expect.objectContaining({
2929
+ id: 'toolu_env',
2930
+ name: Constants.BASH_TOOL,
2931
+ args: { command: 'echo env' },
2932
+ stepId: expect.stringMatching(/^step_/),
2933
+ turn: 0,
2934
+ }),
2935
+ ]);
2936
+ expect(graph.eagerEventToolExecutions.has('toolu_env')).toBe(true);
2937
+ expect(graph.eagerEventToolExecutions.has('toolu_net')).toBe(false);
2938
+ });
2939
+
2940
+ it('does not prestart streamed remote tools when graph tools may appear later', async () => {
2941
+ const graph = createGraph({
2942
+ getAgentContext: jest.fn(
2943
+ (): Partial<AgentContext> => ({
2944
+ provider: Providers.ANTHROPIC,
2945
+ reasoningKey: 'reasoning',
2946
+ toolDefinitions: [
2947
+ { name: Constants.BASH_TOOL },
2948
+ { name: Constants.READ_FILE },
2949
+ ],
2950
+ graphTools: [
2951
+ { name: 'transfer_to_researcher' } as unknown as t.GenericTool,
2952
+ ],
2953
+ agentId: 'agent_1',
2954
+ })
2955
+ ) as unknown as StandardGraph['getAgentContext'],
2956
+ });
2957
+ const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
2958
+ jest
2959
+ .spyOn(events, 'safeDispatchCustomEvent')
2960
+ .mockImplementation(async (event, data): Promise<void> => {
2961
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
2962
+ return;
2963
+ }
2964
+ const batch = data as t.ToolExecuteBatchRequest;
2965
+ toolExecuteCalls.push(batch);
2966
+ batch.resolve(
2967
+ batch.toolCalls.map((call) => ({
2968
+ toolCallId: call.id,
2969
+ status: 'success',
2970
+ content: `ok ${call.name}`,
2971
+ }))
2972
+ );
2973
+ });
2974
+
2975
+ const handler = new ChatModelStreamHandler();
2976
+ const metadata = { langgraph_node: 'agent' };
2125
2977
 
2126
2978
  await handler.handle(
2127
2979
  GraphEvents.CHAT_MODEL_STREAM,
2128
2980
  {
2129
2981
  chunk: {
2130
2982
  content: '',
2131
- tool_calls: [
2983
+ tool_call_chunks: [
2132
2984
  {
2133
- id: 'call_agent_b',
2134
- name: 'weather',
2135
- args: { city: 'SF' },
2985
+ id: 'toolu_env',
2986
+ name: Constants.BASH_TOOL,
2987
+ args: '{"command":"echo env"}',
2988
+ index: 2,
2136
2989
  },
2137
2990
  ],
2138
- response_metadata: finalToolCallResponseMetadata,
2139
2991
  } as unknown as t.StreamChunk,
2140
2992
  },
2141
- { langgraph_node: 'agent_b' },
2993
+ metadata,
2142
2994
  graph
2143
2995
  );
2144
2996
 
2145
- expect(graph.eagerEventToolCallChunks.has(chunkStateKey('agent_b', 0))).toBe(
2146
- false
2147
- );
2148
- expect(
2149
- graph.eagerEventToolCallChunks.get(chunkStateKey('agent_a', 0))?.argsText
2150
- ).toBe('{"city":"NYC"}');
2151
-
2152
2997
  await handler.handle(
2153
2998
  GraphEvents.CHAT_MODEL_STREAM,
2154
2999
  {
2155
3000
  chunk: {
2156
3001
  content: '',
2157
- tool_calls: [
3002
+ tool_call_chunks: [
2158
3003
  {
2159
- id: 'call_agent_a',
2160
- name: 'weather',
2161
- args: { city: 'NYC' },
3004
+ id: 'toolu_net',
3005
+ name: Constants.BASH_TOOL,
3006
+ args: '{"command":"echo net"}',
3007
+ index: 3,
2162
3008
  },
2163
3009
  ],
2164
- response_metadata: finalToolCallResponseMetadata,
2165
3010
  } as unknown as t.StreamChunk,
2166
3011
  },
2167
- { langgraph_node: 'agent_a' },
3012
+ metadata,
2168
3013
  graph
2169
3014
  );
2170
3015
 
2171
- expect(toolExecuteCalls).toHaveLength(2);
2172
- expect(toolExecuteCalls[0].toolCalls[0]).toMatchObject({
2173
- id: 'call_agent_b',
2174
- name: 'weather',
2175
- args: { city: 'SF' },
2176
- });
2177
- expect(toolExecuteCalls[1].toolCalls[0]).toMatchObject({
2178
- id: 'call_agent_a',
2179
- name: 'weather',
2180
- args: { city: 'NYC' },
2181
- });
3016
+ expect(toolExecuteCalls).toHaveLength(0);
3017
+ expect(graph.eagerEventToolExecutions.size).toBe(0);
2182
3018
  expect(graph.eagerEventToolCallChunks.size).toBe(0);
2183
3019
  });
2184
3020
 
2185
- it('does not prestart when batch-sensitive hooks are configured', async () => {
2186
- const graph = createGraph({ hookRegistry: {} as StandardGraph['hookRegistry'] });
2187
- const dispatchSpy = jest.spyOn(events, 'safeDispatchCustomEvent');
3021
+ it('prestarts streamed remote bash tools when code output references are enabled', async () => {
3022
+ const graph = createGraph({
3023
+ toolOutputReferences: { enabled: true },
3024
+ getAgentContext: jest.fn(
3025
+ (): Partial<AgentContext> => ({
3026
+ provider: Providers.ANTHROPIC,
3027
+ reasoningKey: 'reasoning',
3028
+ toolDefinitions: [
3029
+ { name: Constants.BASH_TOOL },
3030
+ { name: Constants.READ_FILE },
3031
+ ],
3032
+ graphTools: [],
3033
+ agentId: 'agent_1',
3034
+ })
3035
+ ) as unknown as StandardGraph['getAgentContext'],
3036
+ });
3037
+ const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
3038
+ jest
3039
+ .spyOn(events, 'safeDispatchCustomEvent')
3040
+ .mockImplementation(async (event, data): Promise<void> => {
3041
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
3042
+ return;
3043
+ }
3044
+ const batch = data as t.ToolExecuteBatchRequest;
3045
+ toolExecuteCalls.push(batch);
3046
+ batch.resolve(
3047
+ batch.toolCalls.map((call) => ({
3048
+ toolCallId: call.id,
3049
+ status: 'success',
3050
+ content: `ok ${call.name}`,
3051
+ }))
3052
+ );
3053
+ });
2188
3054
 
2189
- await new ChatModelStreamHandler().handle(
3055
+ const handler = new ChatModelStreamHandler();
3056
+ const metadata = { langgraph_node: 'agent' };
3057
+
3058
+ for (const args of ['{"command":"echo ', 'env && ', 'pwd"}']) {
3059
+ await handler.handle(
3060
+ GraphEvents.CHAT_MODEL_STREAM,
3061
+ {
3062
+ chunk: {
3063
+ content: '',
3064
+ tool_call_chunks: [
3065
+ {
3066
+ id: 'toolu_env',
3067
+ name: Constants.BASH_TOOL,
3068
+ args,
3069
+ index: 2,
3070
+ },
3071
+ ],
3072
+ } as unknown as t.StreamChunk,
3073
+ },
3074
+ metadata,
3075
+ graph
3076
+ );
3077
+ }
3078
+
3079
+ expect(toolExecuteCalls).toHaveLength(0);
3080
+
3081
+ await handler.handle(
2190
3082
  GraphEvents.CHAT_MODEL_STREAM,
2191
3083
  {
2192
3084
  chunk: {
2193
3085
  content: '',
2194
- tool_calls: [
3086
+ tool_call_chunks: [
2195
3087
  {
2196
- id: 'call_weather',
2197
- name: 'weather',
2198
- args: { city: 'NYC' },
3088
+ id: 'toolu_net',
3089
+ name: Constants.BASH_TOOL,
3090
+ args: '{"command":"echo net"}',
3091
+ index: 3,
2199
3092
  },
2200
3093
  ],
2201
3094
  } as unknown as t.StreamChunk,
2202
3095
  },
2203
- { langgraph_node: 'agent' },
3096
+ metadata,
2204
3097
  graph
2205
3098
  );
2206
3099
 
2207
- expect(dispatchSpy).not.toHaveBeenCalledWith(
2208
- GraphEvents.ON_TOOL_EXECUTE,
2209
- expect.anything(),
2210
- expect.anything()
2211
- );
2212
- expect(graph.eagerEventToolExecutions.size).toBe(0);
3100
+ expect(toolExecuteCalls).toHaveLength(1);
3101
+ expect(toolExecuteCalls[0].toolCalls).toEqual([
3102
+ expect.objectContaining({
3103
+ id: 'toolu_env',
3104
+ name: Constants.BASH_TOOL,
3105
+ args: { command: 'echo env && pwd' },
3106
+ }),
3107
+ ]);
2213
3108
  });
2214
3109
 
2215
- it('does not buffer streamed chunks when eager execution is disabled', async () => {
3110
+ it('does not prestart streamed code tools whose args contain output references', async () => {
2216
3111
  const graph = createGraph({
2217
- eagerEventToolExecution: { enabled: false },
2218
- } as Partial<StandardGraph>);
3112
+ toolOutputReferences: { enabled: true },
3113
+ getAgentContext: jest.fn(
3114
+ (): Partial<AgentContext> => ({
3115
+ provider: Providers.ANTHROPIC,
3116
+ reasoningKey: 'reasoning',
3117
+ toolDefinitions: [
3118
+ { name: Constants.BASH_TOOL },
3119
+ { name: Constants.READ_FILE },
3120
+ ],
3121
+ graphTools: [],
3122
+ agentId: 'agent_1',
3123
+ })
3124
+ ) as unknown as StandardGraph['getAgentContext'],
3125
+ });
2219
3126
  const dispatchSpy = jest.spyOn(events, 'safeDispatchCustomEvent');
3127
+ const handler = new ChatModelStreamHandler();
3128
+ const metadata = { langgraph_node: 'agent' };
2220
3129
 
2221
- await new ChatModelStreamHandler().handle(
3130
+ await handler.handle(
2222
3131
  GraphEvents.CHAT_MODEL_STREAM,
2223
3132
  {
2224
3133
  chunk: {
2225
3134
  content: '',
2226
3135
  tool_call_chunks: [
2227
3136
  {
2228
- id: 'call_weather',
2229
- name: 'weather',
2230
- args: '{"city":"NYC"}',
2231
- index: 0,
3137
+ id: 'toolu_ref',
3138
+ name: Constants.BASH_TOOL,
3139
+ args: '{"command":"cat <<EOF\\n{{tool0turn0}}\\nEOF"}',
3140
+ index: 2,
2232
3141
  },
2233
3142
  ],
2234
3143
  } as unknown as t.StreamChunk,
2235
3144
  },
2236
- { langgraph_node: 'agent' },
3145
+ metadata,
3146
+ graph
3147
+ );
3148
+ await handler.handle(
3149
+ GraphEvents.CHAT_MODEL_STREAM,
3150
+ {
3151
+ chunk: {
3152
+ content: '',
3153
+ tool_call_chunks: [
3154
+ {
3155
+ id: 'toolu_next',
3156
+ name: Constants.BASH_TOOL,
3157
+ args: '{"command":"echo next"}',
3158
+ index: 3,
3159
+ },
3160
+ ],
3161
+ } as unknown as t.StreamChunk,
3162
+ },
3163
+ metadata,
2237
3164
  graph
2238
3165
  );
2239
3166
 
@@ -2242,42 +3169,63 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
2242
3169
  expect.anything(),
2243
3170
  expect.anything()
2244
3171
  );
2245
- expect(graph.eagerEventToolExecutions.size).toBe(0);
2246
- expect(graph.eagerEventToolCallChunks.size).toBe(0);
2247
3172
  });
2248
3173
 
2249
- it('does not prestart local-engine direct coding tools', async () => {
3174
+ it('does not prestart streamed tools when the next Anthropic tool call is a graph tool', async () => {
3175
+ const handoffToolName = `${Constants.LC_TRANSFER_TO_}researcher`;
2250
3176
  const graph = createGraph({
2251
- toolExecution: {
2252
- engine: 'local',
2253
- } as StandardGraph['toolExecution'],
2254
3177
  getAgentContext: jest.fn(
2255
3178
  (): Partial<AgentContext> => ({
2256
- provider: Providers.OPENAI,
2257
- reasoningKey: 'reasoning_content',
2258
- toolDefinitions: [{ name: Constants.EXECUTE_CODE }],
2259
- graphTools: [],
3179
+ provider: Providers.ANTHROPIC,
3180
+ reasoningKey: 'reasoning',
3181
+ toolDefinitions: [
3182
+ { name: Constants.BASH_TOOL },
3183
+ { name: handoffToolName },
3184
+ ],
3185
+ graphTools: [{ name: handoffToolName } as unknown as t.GenericTool],
2260
3186
  agentId: 'agent_1',
2261
3187
  })
2262
3188
  ) as unknown as StandardGraph['getAgentContext'],
2263
3189
  });
2264
3190
  const dispatchSpy = jest.spyOn(events, 'safeDispatchCustomEvent');
3191
+ const handler = new ChatModelStreamHandler();
3192
+ const metadata = { langgraph_node: 'agent' };
2265
3193
 
2266
- await new ChatModelStreamHandler().handle(
3194
+ await handler.handle(
2267
3195
  GraphEvents.CHAT_MODEL_STREAM,
2268
3196
  {
2269
3197
  chunk: {
2270
3198
  content: '',
2271
- tool_calls: [
3199
+ tool_call_chunks: [
2272
3200
  {
2273
- id: 'call_code',
2274
- name: Constants.EXECUTE_CODE,
2275
- args: { code: 'print(1)' },
3201
+ id: 'toolu_env',
3202
+ name: Constants.BASH_TOOL,
3203
+ args: '{"command":"echo env"}',
3204
+ index: 2,
2276
3205
  },
2277
3206
  ],
2278
3207
  } as unknown as t.StreamChunk,
2279
3208
  },
2280
- { langgraph_node: 'agent' },
3209
+ metadata,
3210
+ graph
3211
+ );
3212
+
3213
+ await handler.handle(
3214
+ GraphEvents.CHAT_MODEL_STREAM,
3215
+ {
3216
+ chunk: {
3217
+ content: '',
3218
+ tool_call_chunks: [
3219
+ {
3220
+ id: 'toolu_handoff',
3221
+ name: handoffToolName,
3222
+ args: '{"message":"check this"}',
3223
+ index: 3,
3224
+ },
3225
+ ],
3226
+ } as unknown as t.StreamChunk,
3227
+ },
3228
+ metadata,
2281
3229
  graph
2282
3230
  );
2283
3231
 
@@ -2287,20 +3235,20 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
2287
3235
  expect.anything()
2288
3236
  );
2289
3237
  expect(graph.eagerEventToolExecutions.size).toBe(0);
2290
- expect(graph.eagerEventToolCallChunks.size).toBe(0);
2291
3238
  });
2292
3239
 
2293
- it('does not prestart streamed local-engine direct coding tools', async () => {
3240
+ it('does not prestart streamed tools after a graph tool appeared earlier in the same step', async () => {
3241
+ const handoffToolName = `${Constants.LC_TRANSFER_TO_}researcher`;
2294
3242
  const graph = createGraph({
2295
- toolExecution: {
2296
- engine: 'local',
2297
- } as StandardGraph['toolExecution'],
2298
3243
  getAgentContext: jest.fn(
2299
3244
  (): Partial<AgentContext> => ({
2300
- provider: Providers.OPENAI,
2301
- reasoningKey: 'reasoning_content',
2302
- toolDefinitions: [{ name: Constants.EXECUTE_CODE }, { name: 'weather' }],
2303
- graphTools: [],
3245
+ provider: Providers.ANTHROPIC,
3246
+ reasoningKey: 'reasoning',
3247
+ toolDefinitions: [
3248
+ { name: Constants.BASH_TOOL },
3249
+ { name: handoffToolName },
3250
+ ],
3251
+ graphTools: [{ name: handoffToolName } as unknown as t.GenericTool],
2304
3252
  agentId: 'agent_1',
2305
3253
  })
2306
3254
  ) as unknown as StandardGraph['getAgentContext'],
@@ -2316,10 +3264,10 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
2316
3264
  content: '',
2317
3265
  tool_call_chunks: [
2318
3266
  {
2319
- id: 'call_weather',
2320
- name: 'weather',
2321
- args: '{"city":"NYC"}',
2322
- index: 0,
3267
+ id: 'toolu_env',
3268
+ name: Constants.BASH_TOOL,
3269
+ args: '{"command":"echo env"}',
3270
+ index: 2,
2323
3271
  },
2324
3272
  ],
2325
3273
  } as unknown as t.StreamChunk,
@@ -2334,10 +3282,28 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
2334
3282
  content: '',
2335
3283
  tool_call_chunks: [
2336
3284
  {
2337
- id: 'call_code',
2338
- name: Constants.EXECUTE_CODE,
2339
- args: '{"code":"print(1)"}',
2340
- index: 1,
3285
+ id: 'toolu_handoff',
3286
+ name: handoffToolName,
3287
+ args: '{"message":"partial',
3288
+ index: 3,
3289
+ },
3290
+ ],
3291
+ } as unknown as t.StreamChunk,
3292
+ },
3293
+ metadata,
3294
+ graph
3295
+ );
3296
+ await handler.handle(
3297
+ GraphEvents.CHAT_MODEL_STREAM,
3298
+ {
3299
+ chunk: {
3300
+ content: '',
3301
+ tool_call_chunks: [
3302
+ {
3303
+ id: 'toolu_next',
3304
+ name: Constants.BASH_TOOL,
3305
+ args: '{"command":"echo next"}',
3306
+ index: 4,
2341
3307
  },
2342
3308
  ],
2343
3309
  } as unknown as t.StreamChunk,
@@ -2352,7 +3318,6 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
2352
3318
  expect.anything()
2353
3319
  );
2354
3320
  expect(graph.eagerEventToolExecutions.size).toBe(0);
2355
- expect(graph.eagerEventToolCallChunks.size).toBe(0);
2356
3321
  });
2357
3322
 
2358
3323
  it('does not prestart event tools in a mixed direct-tool batch', async () => {
@@ -2411,8 +3376,9 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
2411
3376
  const graph = createGraph();
2412
3377
  graph.eagerEventToolUsageCount.set('weather', 1);
2413
3378
  const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
2414
- jest.spyOn(events, 'safeDispatchCustomEvent').mockImplementation(
2415
- async (event, data): Promise<void> => {
3379
+ jest
3380
+ .spyOn(events, 'safeDispatchCustomEvent')
3381
+ .mockImplementation(async (event, data): Promise<void> => {
2416
3382
  if (event !== GraphEvents.ON_TOOL_EXECUTE) {
2417
3383
  return;
2418
3384
  }
@@ -2425,8 +3391,7 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
2425
3391
  content: 'sunny',
2426
3392
  },
2427
3393
  ]);
2428
- }
2429
- );
3394
+ });
2430
3395
 
2431
3396
  await new ChatModelStreamHandler().handle(
2432
3397
  GraphEvents.CHAT_MODEL_STREAM,