@librechat/agents 3.2.61 → 3.2.62

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.
Files changed (116) hide show
  1. package/dist/cjs/common/enum.cjs +2 -0
  2. package/dist/cjs/common/enum.cjs.map +1 -1
  3. package/dist/cjs/hooks/HookRegistry.cjs +32 -0
  4. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
  5. package/dist/cjs/hooks/executeHooks.cjs +6 -0
  6. package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
  7. package/dist/cjs/hooks/index.cjs +12 -0
  8. package/dist/cjs/hooks/index.cjs.map +1 -0
  9. package/dist/cjs/hooks/types.cjs.map +1 -1
  10. package/dist/cjs/langfuse.cjs +62 -1
  11. package/dist/cjs/langfuse.cjs.map +1 -1
  12. package/dist/cjs/llm/bedrock/index.cjs +266 -43
  13. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  14. package/dist/cjs/llm/openai/index.cjs +274 -7
  15. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  16. package/dist/cjs/llm/openai/streamMetadata.cjs +69 -0
  17. package/dist/cjs/llm/openai/streamMetadata.cjs.map +1 -0
  18. package/dist/cjs/llm/openrouter/index.cjs +5 -6
  19. package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
  20. package/dist/cjs/main.cjs +6 -1
  21. package/dist/cjs/messages/format.cjs +61 -0
  22. package/dist/cjs/messages/format.cjs.map +1 -1
  23. package/dist/cjs/messages/prune.cjs +31 -19
  24. package/dist/cjs/messages/prune.cjs.map +1 -1
  25. package/dist/cjs/stream.cjs +13 -3
  26. package/dist/cjs/stream.cjs.map +1 -1
  27. package/dist/cjs/tools/ToolNode.cjs +75 -10
  28. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  29. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -0
  30. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  31. package/dist/cjs/utils/tokens.cjs +111 -0
  32. package/dist/cjs/utils/tokens.cjs.map +1 -1
  33. package/dist/esm/common/enum.mjs +2 -0
  34. package/dist/esm/common/enum.mjs.map +1 -1
  35. package/dist/esm/hooks/HookRegistry.mjs +32 -0
  36. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  37. package/dist/esm/hooks/executeHooks.mjs +6 -0
  38. package/dist/esm/hooks/executeHooks.mjs.map +1 -1
  39. package/dist/esm/hooks/index.mjs +12 -1
  40. package/dist/esm/hooks/index.mjs.map +1 -0
  41. package/dist/esm/hooks/types.mjs.map +1 -1
  42. package/dist/esm/langfuse.mjs +62 -1
  43. package/dist/esm/langfuse.mjs.map +1 -1
  44. package/dist/esm/llm/bedrock/index.mjs +266 -43
  45. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  46. package/dist/esm/llm/openai/index.mjs +274 -7
  47. package/dist/esm/llm/openai/index.mjs.map +1 -1
  48. package/dist/esm/llm/openai/streamMetadata.mjs +69 -0
  49. package/dist/esm/llm/openai/streamMetadata.mjs.map +1 -0
  50. package/dist/esm/llm/openrouter/index.mjs +5 -6
  51. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  52. package/dist/esm/main.mjs +3 -3
  53. package/dist/esm/messages/format.mjs +61 -0
  54. package/dist/esm/messages/format.mjs.map +1 -1
  55. package/dist/esm/messages/prune.mjs +31 -19
  56. package/dist/esm/messages/prune.mjs.map +1 -1
  57. package/dist/esm/stream.mjs +13 -3
  58. package/dist/esm/stream.mjs.map +1 -1
  59. package/dist/esm/tools/ToolNode.mjs +75 -10
  60. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  61. package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -0
  62. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  63. package/dist/esm/utils/tokens.mjs +108 -1
  64. package/dist/esm/utils/tokens.mjs.map +1 -1
  65. package/dist/types/common/enum.d.ts +3 -1
  66. package/dist/types/hooks/HookRegistry.d.ts +10 -0
  67. package/dist/types/hooks/index.d.ts +7 -0
  68. package/dist/types/hooks/types.d.ts +18 -1
  69. package/dist/types/llm/bedrock/index.d.ts +5 -0
  70. package/dist/types/llm/openai/index.d.ts +19 -0
  71. package/dist/types/llm/openai/streamMetadata.d.ts +16 -0
  72. package/dist/types/messages/prune.d.ts +4 -3
  73. package/dist/types/tools/ToolNode.d.ts +1 -0
  74. package/dist/types/types/llm.d.ts +12 -2
  75. package/dist/types/types/tools.d.ts +1 -1
  76. package/dist/types/utils/tokens.d.ts +30 -0
  77. package/package.json +6 -6
  78. package/src/__tests__/stream.eagerEventExecution.test.ts +100 -2
  79. package/src/common/enum.ts +2 -0
  80. package/src/hooks/HookRegistry.ts +45 -0
  81. package/src/hooks/__tests__/HookRegistry.test.ts +48 -0
  82. package/src/hooks/__tests__/executeHooks.test.ts +85 -2
  83. package/src/hooks/executeHooks.ts +15 -0
  84. package/src/hooks/index.ts +7 -0
  85. package/src/hooks/types.ts +18 -1
  86. package/src/langfuse.ts +134 -1
  87. package/src/llm/bedrock/index.ts +434 -83
  88. package/src/llm/bedrock/streamSealDispatch.test.ts +97 -0
  89. package/src/llm/custom-chat-models.smoke.test.ts +7 -0
  90. package/src/llm/openai/index.ts +604 -6
  91. package/src/llm/openai/managedRequests.test.ts +182 -0
  92. package/src/llm/openai/streamMetadata.spec.ts +86 -0
  93. package/src/llm/openai/streamMetadata.ts +95 -0
  94. package/src/llm/openai/streamMetadataDedup.spec.ts +166 -0
  95. package/src/llm/openrouter/index.ts +9 -5
  96. package/src/messages/format.ts +96 -3
  97. package/src/messages/formatAgentMessages.steer.test.ts +326 -0
  98. package/src/messages/labelContentByAgent.test.ts +75 -0
  99. package/src/messages/prune.ts +56 -30
  100. package/src/specs/anthropic.simple.test.ts +4 -2
  101. package/src/specs/cache.simple.test.ts +17 -4
  102. package/src/specs/langfuse-callbacks.test.ts +61 -0
  103. package/src/specs/openai.simple.test.ts +4 -2
  104. package/src/specs/spec.utils.ts +12 -0
  105. package/src/specs/summarization.test.ts +9 -13
  106. package/src/specs/token-accounting-pipeline.test.ts +130 -4
  107. package/src/specs/tokens.test.ts +214 -0
  108. package/src/stream.ts +22 -3
  109. package/src/tools/ToolNode.ts +107 -12
  110. package/src/tools/__tests__/ToolNode.eagerEventExecution.test.ts +554 -0
  111. package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +49 -2
  112. package/src/tools/__tests__/hitl.test.ts +112 -0
  113. package/src/tools/subagent/SubagentExecutor.ts +2 -0
  114. package/src/types/llm.ts +12 -2
  115. package/src/types/tools.ts +1 -1
  116. package/src/utils/tokens.ts +181 -3
@@ -39,6 +39,7 @@ import {
39
39
  handleConverseStreamContentBlockDelta,
40
40
  handleConverseStreamMetadata,
41
41
  } from './utils';
42
+ import type { ContentBlockDeltaEvent } from './types';
42
43
  import {
43
44
  resolveBedrockPromptCacheTtl,
44
45
  supportsBedrockToolCache,
@@ -57,6 +58,96 @@ export type ServiceTierType = 'priority' | 'default' | 'flex' | 'reserved';
57
58
  export type CustomGuardrailConfiguration = GuardrailConfiguration &
58
59
  Pick<GuardrailStreamConfiguration, 'streamProcessingMode'>;
59
60
 
61
+ const MAX_STREAM_QUEUE_CHUNKS = 256;
62
+ const MAX_STREAM_QUEUE_TEXT_CHARS = 8192;
63
+ const STREAM_CHUNK_MIN_SIZE = 4;
64
+ const STREAM_BOUNDARIES = new Set([' ', '.', ',', '!', '?', ';', ':']);
65
+
66
+ type QueuedGenerationChunk = {
67
+ chunk: ChatGenerationChunk;
68
+ callbackChunk?: ChatGenerationChunk;
69
+ callbackToken: string;
70
+ smooth: boolean;
71
+ textLength: number;
72
+ };
73
+
74
+ function findStreamChunkBoundary(text: string, minSize: number): number {
75
+ if (minSize >= text.length) {
76
+ return text.length;
77
+ }
78
+
79
+ for (let position = minSize; position < text.length; position++) {
80
+ if (STREAM_BOUNDARIES.has(text[position])) {
81
+ return position + 1;
82
+ }
83
+ }
84
+
85
+ return text.length;
86
+ }
87
+
88
+ function splitStreamToken(text: string): string[] {
89
+ const chunks: string[] = [];
90
+ let currentIndex = 0;
91
+
92
+ while (currentIndex < text.length) {
93
+ const remainingText = text.slice(currentIndex);
94
+ const chunkSize = findStreamChunkBoundary(
95
+ remainingText,
96
+ STREAM_CHUNK_MIN_SIZE
97
+ );
98
+ chunks.push(text.slice(currentIndex, currentIndex + chunkSize));
99
+ currentIndex += chunkSize;
100
+ }
101
+
102
+ return chunks;
103
+ }
104
+
105
+ function getCadencedStreamDelay({
106
+ targetDelay,
107
+ lastVisibleContentAt,
108
+ now,
109
+ }: {
110
+ targetDelay: number;
111
+ lastVisibleContentAt?: number;
112
+ now: number;
113
+ }): number {
114
+ if (targetDelay <= 0 || lastVisibleContentAt == null) {
115
+ return 0;
116
+ }
117
+ return Math.max(0, targetDelay - (now - lastVisibleContentAt));
118
+ }
119
+
120
+ async function waitForStreamDelay(
121
+ delay: number,
122
+ signal?: AbortSignal
123
+ ): Promise<void> {
124
+ if (delay <= 0 || isSignalAborted(signal)) {
125
+ return;
126
+ }
127
+ await new Promise<void>((resolve) => {
128
+ const timeoutRef: { current?: ReturnType<typeof setTimeout> } = {};
129
+ const onAbort = (): void => {
130
+ if (timeoutRef.current) {
131
+ clearTimeout(timeoutRef.current);
132
+ }
133
+ signal?.removeEventListener('abort', onAbort);
134
+ resolve();
135
+ };
136
+ timeoutRef.current = setTimeout(() => {
137
+ signal?.removeEventListener('abort', onAbort);
138
+ resolve();
139
+ }, delay);
140
+ signal?.addEventListener('abort', onAbort, { once: true });
141
+ if (isSignalAborted(signal)) {
142
+ onAbort();
143
+ }
144
+ });
145
+ }
146
+
147
+ function isSignalAborted(signal?: AbortSignal): boolean {
148
+ return signal?.aborted === true;
149
+ }
150
+
60
151
  /**
61
152
  * Extended input interface with additional features:
62
153
  * - applicationInferenceProfile: Use an inference profile ARN instead of model ID
@@ -78,6 +169,11 @@ export interface CustomChatBedrockConverseInput
78
169
  */
79
170
  promptCacheTtl?: PromptCacheTtl;
80
171
 
172
+ /**
173
+ * Minimum delay in milliseconds between visible streamed content deltas.
174
+ */
175
+ _lc_stream_delay?: number;
176
+
81
177
  /**
82
178
  * Guardrail configuration for Converse and ConverseStream invocations.
83
179
  * `streamProcessingMode` is only used by ConverseStream.
@@ -119,6 +215,8 @@ export interface CustomChatBedrockConverseCallOptions {
119
215
  }
120
216
 
121
217
  export class CustomChatBedrockConverse extends ChatBedrockConverse {
218
+ _lc_stream_delay: number;
219
+
122
220
  /**
123
221
  * Whether to insert Bedrock prompt cache checkpoints when available.
124
222
  */
@@ -151,6 +249,7 @@ export class CustomChatBedrockConverse extends ChatBedrockConverse {
151
249
  super(fields);
152
250
  this.promptCache = fields?.promptCache;
153
251
  this.promptCacheTtl = fields?.promptCacheTtl;
252
+ this._lc_stream_delay = Math.max(0, fields?._lc_stream_delay ?? 0);
154
253
  this.applicationInferenceProfile = fields?.applicationInferenceProfile;
155
254
  this.serviceTier = fields?.serviceTier;
156
255
  // `super(fields)` initializes `this.model` to LangChain's default Claude
@@ -183,10 +282,10 @@ export class CustomChatBedrockConverse extends ChatBedrockConverse {
183
282
  const toolConfig =
184
283
  this.promptCache === true && supportsBedrockToolCache(this.cacheModelId)
185
284
  ? insertBedrockToolCachePoint(
186
- baseParams.toolConfig,
187
- true,
188
- resolveBedrockPromptCacheTtl(this.promptCacheTtl, this.cacheModelId)
189
- )
285
+ baseParams.toolConfig,
286
+ true,
287
+ resolveBedrockPromptCacheTtl(this.promptCacheTtl, this.cacheModelId)
288
+ )
190
289
  : baseParams.toolConfig;
191
290
 
192
291
  /** Service tier from options or fall back to class-level setting */
@@ -261,102 +360,354 @@ export class CustomChatBedrockConverse extends ChatBedrockConverse {
261
360
  ...(params as Record<string, unknown>),
262
361
  });
263
362
 
264
- const response = await this.client.send(command, {
265
- abortSignal: options.signal,
266
- });
267
-
268
- if (!response.stream) {
269
- return;
363
+ const streamAbortController = new AbortController();
364
+ const abortStream = (): void => streamAbortController.abort();
365
+ options.signal?.addEventListener('abort', abortStream, { once: true });
366
+ if (isSignalAborted(options.signal)) {
367
+ abortStream();
270
368
  }
271
369
 
272
- const seenBlockIndices = new Set<number>();
273
- const toolUseBlockIndices = new Set<number>();
274
- /**
275
- * Guardrails can reject an already-streamed toolUse block at
276
- * `messageStop` (`guardrail_intervened`), after `contentBlockStop` has
277
- * passed. Only emit eager-execution seals when no guardrails are
278
- * configured, so a later intervention can't race an eagerly started tool.
279
- */
280
- const sealToolUseOnStop =
281
- options.guardrailConfig == null && this.guardrailConfig == null;
282
-
283
- for await (const event of response.stream) {
284
- if (event.contentBlockStart != null) {
285
- const startChunk = handleConverseStreamContentBlockStart(
286
- event.contentBlockStart
287
- );
288
- if (startChunk != null) {
289
- const idx = event.contentBlockStart.contentBlockIndex;
290
- if (idx != null) {
291
- seenBlockIndices.add(idx);
292
- if (event.contentBlockStart.start?.toolUse != null) {
293
- toolUseBlockIndices.add(idx);
370
+ try {
371
+ const response = await this.client.send(command, {
372
+ abortSignal: streamAbortController.signal,
373
+ });
374
+
375
+ const stream = response.stream;
376
+ if (!stream) {
377
+ return;
378
+ }
379
+
380
+ const seenBlockIndices = new Set<number>();
381
+ const toolUseBlockIndices = new Set<number>();
382
+ const queuedChunks: QueuedGenerationChunk[] = [];
383
+ const producerState: { done: boolean; error?: unknown } = { done: false };
384
+ let queuedChunkIndex = 0;
385
+ let bufferedTextLength = 0;
386
+ let consumerClosed = false;
387
+ let notifyConsumer: (() => void) | undefined;
388
+ let notifyProducer: (() => void) | undefined;
389
+ let hasEmittedVisibleContent = false;
390
+ let lastVisibleContentAt: number | undefined;
391
+
392
+ /**
393
+ * Guardrails can reject an already-streamed toolUse block at
394
+ * `messageStop` (`guardrail_intervened`), after `contentBlockStop` has
395
+ * passed. Only emit eager-execution seals when no guardrails are
396
+ * configured, so a later intervention can't race an eagerly started tool.
397
+ */
398
+ const sealToolUseOnStop =
399
+ options.guardrailConfig == null && this.guardrailConfig == null;
400
+
401
+ const notifyConsumerForChunk = (): void => {
402
+ notifyConsumer?.();
403
+ notifyConsumer = undefined;
404
+ };
405
+
406
+ const notifyProducerForSpace = (): void => {
407
+ notifyProducer?.();
408
+ notifyProducer = undefined;
409
+ };
410
+
411
+ const hasQueuedChunks = (): boolean =>
412
+ queuedChunkIndex < queuedChunks.length;
413
+
414
+ const getQueuedChunkCount = (): number =>
415
+ queuedChunks.length - queuedChunkIndex;
416
+
417
+ const isQueueAtCapacity = (): boolean =>
418
+ getQueuedChunkCount() >= MAX_STREAM_QUEUE_CHUNKS ||
419
+ bufferedTextLength >= MAX_STREAM_QUEUE_TEXT_CHARS;
420
+
421
+ const waitForNextChunk = async (): Promise<void> => {
422
+ if (
423
+ hasQueuedChunks() ||
424
+ producerState.done ||
425
+ producerState.error != null
426
+ ) {
427
+ return;
428
+ }
429
+ await new Promise<void>((resolve) => {
430
+ notifyConsumer = resolve;
431
+ });
432
+ };
433
+
434
+ const waitForQueueSpace = async (): Promise<void> => {
435
+ while (
436
+ isQueueAtCapacity() &&
437
+ !consumerClosed &&
438
+ !isSignalAborted(options.signal)
439
+ ) {
440
+ await new Promise<void>((resolve) => {
441
+ const signal = options.signal;
442
+ const onAbort = (): void => {
443
+ signal?.removeEventListener('abort', onAbort);
444
+ resolve();
445
+ };
446
+ const onSpace = (): void => {
447
+ signal?.removeEventListener('abort', onAbort);
448
+ resolve();
449
+ };
450
+ notifyProducer = onSpace;
451
+ signal?.addEventListener('abort', onAbort, { once: true });
452
+ if (isSignalAborted(signal)) {
453
+ onAbort();
294
454
  }
295
- }
296
- yield this.enrichChunk(startChunk, seenBlockIndices);
297
-
298
- // Registered stream handlers receive chunks through callback
299
- // events, not the yielded generator — dispatch the start chunk so
300
- // they see the tool call's id/name (eager chunk state needs both).
301
- await runManager?.handleLLMNewToken(
302
- startChunk.text,
303
- undefined,
304
- undefined,
305
- undefined,
306
- undefined,
307
- { chunk: startChunk }
308
- );
455
+ });
309
456
  }
310
- } else if (event.contentBlockDelta != null) {
311
- const deltaChunk = handleConverseStreamContentBlockDelta(
312
- event.contentBlockDelta
313
- );
457
+ };
314
458
 
315
- const idx = event.contentBlockDelta.contentBlockIndex;
459
+ const dequeue = (): QueuedGenerationChunk | undefined => {
460
+ if (!hasQueuedChunks()) {
461
+ return undefined;
462
+ }
463
+ const queuedChunk = queuedChunks[queuedChunkIndex];
464
+ queuedChunkIndex++;
465
+ if (
466
+ queuedChunkIndex > 128 &&
467
+ queuedChunkIndex * 2 >= queuedChunks.length
468
+ ) {
469
+ queuedChunks.splice(0, queuedChunkIndex);
470
+ queuedChunkIndex = 0;
471
+ }
472
+ return queuedChunk;
473
+ };
474
+
475
+ const enqueue = async (
476
+ queuedChunk: QueuedGenerationChunk
477
+ ): Promise<void> => {
478
+ await waitForQueueSpace();
479
+ if (consumerClosed || isSignalAborted(options.signal)) {
480
+ abortStream();
481
+ throw new Error('AbortError: User aborted the request.');
482
+ }
483
+ queuedChunks.push(queuedChunk);
484
+ if (queuedChunk.smooth) {
485
+ bufferedTextLength += queuedChunk.textLength;
486
+ }
487
+ notifyConsumerForChunk();
488
+ };
489
+
490
+ const enqueueChunk = async ({
491
+ chunk,
492
+ callbackChunk,
493
+ callbackToken = '',
494
+ smooth = false,
495
+ textLength = 0,
496
+ }: {
497
+ chunk: ChatGenerationChunk;
498
+ callbackChunk?: ChatGenerationChunk;
499
+ callbackToken?: string;
500
+ smooth?: boolean;
501
+ textLength?: number;
502
+ }): Promise<void> => {
503
+ await enqueue({
504
+ chunk,
505
+ callbackChunk,
506
+ callbackToken,
507
+ smooth,
508
+ textLength: smooth ? textLength : 0,
509
+ });
510
+ };
511
+
512
+ const enqueueDelta = async (
513
+ contentBlockDelta: ContentBlockDeltaEvent
514
+ ): Promise<void> => {
515
+ const delta = contentBlockDelta.delta;
516
+ if (delta == null) {
517
+ throw new Error('No delta found in content block.');
518
+ }
519
+
520
+ const idx = contentBlockDelta.contentBlockIndex;
316
521
  if (idx != null) {
317
522
  seenBlockIndices.add(idx);
318
523
  }
319
524
 
320
- yield this.enrichChunk(deltaChunk, seenBlockIndices);
321
-
322
- await runManager?.handleLLMNewToken(
323
- deltaChunk.text,
324
- undefined,
325
- undefined,
326
- undefined,
327
- undefined,
328
- { chunk: deltaChunk }
329
- );
330
- } else if (event.metadata != null) {
331
- yield handleConverseStreamMetadata(event.metadata, { streamUsage });
332
- } else if (event.contentBlockStop != null) {
333
- const stopIdx = event.contentBlockStop.contentBlockIndex;
334
- if (stopIdx != null) {
335
- seenBlockIndices.add(stopIdx);
336
- if (sealToolUseOnStop && toolUseBlockIndices.has(stopIdx)) {
337
- // Converse guarantees the block's input is complete at stop, so
338
- // emit an explicit seal chunk for eager tool execution — through
339
- // the callback path too, for registered stream handlers.
340
- const sealChunk = createConverseToolUseStopChunk(stopIdx);
341
- yield sealChunk;
525
+ const text = delta.text;
526
+ const reasoningContent = delta.reasoningContent;
527
+ const reasoningText = reasoningContent?.text;
528
+ const visibleText =
529
+ typeof text === 'string'
530
+ ? text
531
+ : typeof reasoningText === 'string'
532
+ ? reasoningText
533
+ : '';
534
+ const smooth = this._lc_stream_delay > 0 && visibleText !== '';
535
+ const tokenChunks = smooth
536
+ ? splitStreamToken(visibleText)
537
+ : [visibleText];
538
+
539
+ for (const token of tokenChunks) {
540
+ let splitDelta = contentBlockDelta;
541
+ if (typeof text === 'string') {
542
+ splitDelta = {
543
+ ...contentBlockDelta,
544
+ delta: { text: token },
545
+ };
546
+ } else if (
547
+ typeof reasoningText === 'string' &&
548
+ reasoningContent != null
549
+ ) {
550
+ splitDelta = {
551
+ ...contentBlockDelta,
552
+ delta: {
553
+ reasoningContent: {
554
+ ...reasoningContent,
555
+ text: token,
556
+ },
557
+ },
558
+ };
559
+ }
560
+
561
+ const deltaChunk = handleConverseStreamContentBlockDelta(splitDelta);
562
+ await enqueueChunk({
563
+ chunk: this.enrichChunk(deltaChunk, seenBlockIndices),
564
+ callbackChunk: deltaChunk,
565
+ callbackToken: deltaChunk.text,
566
+ smooth,
567
+ textLength: token.length,
568
+ });
569
+ }
570
+ };
571
+
572
+ const producer = (async (): Promise<void> => {
573
+ try {
574
+ for await (const event of stream) {
575
+ if (isSignalAborted(options.signal)) {
576
+ abortStream();
577
+ throw new Error('AbortError: User aborted the request.');
578
+ }
579
+
580
+ if (event.contentBlockStart != null) {
581
+ const startChunk = handleConverseStreamContentBlockStart(
582
+ event.contentBlockStart
583
+ );
584
+ if (startChunk != null) {
585
+ const idx = event.contentBlockStart.contentBlockIndex;
586
+ if (idx != null) {
587
+ seenBlockIndices.add(idx);
588
+ if (event.contentBlockStart.start?.toolUse != null) {
589
+ toolUseBlockIndices.add(idx);
590
+ }
591
+ }
592
+ await enqueueChunk({
593
+ chunk: this.enrichChunk(startChunk, seenBlockIndices),
594
+ callbackChunk: startChunk,
595
+ callbackToken: startChunk.text,
596
+ });
597
+ }
598
+ } else if (event.contentBlockDelta != null) {
599
+ await enqueueDelta(event.contentBlockDelta);
600
+ } else if (event.metadata != null) {
601
+ await enqueueChunk({
602
+ chunk: handleConverseStreamMetadata(event.metadata, {
603
+ streamUsage,
604
+ }),
605
+ });
606
+ } else if (event.contentBlockStop != null) {
607
+ const stopIdx = event.contentBlockStop.contentBlockIndex;
608
+ if (stopIdx != null) {
609
+ seenBlockIndices.add(stopIdx);
610
+ if (sealToolUseOnStop && toolUseBlockIndices.has(stopIdx)) {
611
+ const sealChunk = createConverseToolUseStopChunk(stopIdx);
612
+ await enqueueChunk({
613
+ chunk: sealChunk,
614
+ callbackChunk: sealChunk,
615
+ callbackToken: sealChunk.text,
616
+ });
617
+ }
618
+ }
619
+ } else {
620
+ await enqueueChunk({
621
+ chunk: new ChatGenerationChunk({
622
+ text: '',
623
+ message: new AIMessageChunk({
624
+ content: '',
625
+ response_metadata: { ...event } as ResponseMetadata,
626
+ }),
627
+ }),
628
+ });
629
+ }
630
+ }
631
+ } catch (error) {
632
+ producerState.error = error;
633
+ } finally {
634
+ producerState.done = true;
635
+ notifyConsumerForChunk();
636
+ }
637
+ })();
638
+
639
+ try {
640
+ let keepStreaming = true;
641
+ while (keepStreaming) {
642
+ if (isSignalAborted(options.signal)) {
643
+ abortStream();
644
+ throw new Error('AbortError: User aborted the request.');
645
+ }
646
+
647
+ await waitForNextChunk();
648
+ const queuedChunk = dequeue();
649
+
650
+ if (!queuedChunk) {
651
+ if (producerState.error != null) {
652
+ throw producerState.error;
653
+ }
654
+ if (producerState.done) {
655
+ keepStreaming = false;
656
+ }
657
+ continue;
658
+ }
659
+
660
+ if (queuedChunk.smooth) {
661
+ bufferedTextLength = Math.max(
662
+ 0,
663
+ bufferedTextLength - queuedChunk.textLength
664
+ );
665
+ notifyProducerForSpace();
666
+ await waitForStreamDelay(
667
+ getCadencedStreamDelay({
668
+ targetDelay: hasEmittedVisibleContent
669
+ ? this._lc_stream_delay
670
+ : 0,
671
+ lastVisibleContentAt,
672
+ now: Date.now(),
673
+ }),
674
+ options.signal
675
+ );
676
+ if (isSignalAborted(options.signal)) {
677
+ abortStream();
678
+ throw new Error('AbortError: User aborted the request.');
679
+ }
680
+ hasEmittedVisibleContent = true;
681
+ lastVisibleContentAt = Date.now();
682
+ } else {
683
+ notifyProducerForSpace();
684
+ }
685
+
686
+ yield queuedChunk.chunk;
687
+
688
+ if (queuedChunk.callbackChunk != null) {
342
689
  await runManager?.handleLLMNewToken(
343
- sealChunk.text,
690
+ queuedChunk.callbackToken,
344
691
  undefined,
345
692
  undefined,
346
693
  undefined,
347
694
  undefined,
348
- { chunk: sealChunk }
695
+ { chunk: queuedChunk.callbackChunk }
349
696
  );
350
697
  }
351
698
  }
352
- } else {
353
- yield new ChatGenerationChunk({
354
- text: '',
355
- message: new AIMessageChunk({
356
- content: '',
357
- response_metadata: { ...event } as ResponseMetadata,
358
- }),
359
- });
699
+ } finally {
700
+ consumerClosed = true;
701
+ if (!producerState.done) {
702
+ abortStream();
703
+ notifyProducerForSpace();
704
+ }
705
+ await producer;
706
+ }
707
+ } finally {
708
+ options.signal?.removeEventListener('abort', abortStream);
709
+ if (!streamAbortController.signal.aborted) {
710
+ streamAbortController.abort();
360
711
  }
361
712
  }
362
713
  }
@@ -155,4 +155,101 @@ describe('Converse stream seal dispatch', () => {
155
155
  expect(dispatched.some(hasSeal)).toBe(false);
156
156
  expect(dispatched).toHaveLength(1);
157
157
  });
158
+
159
+ test('paces visible text deltas with configured stream delay', async () => {
160
+ const setTimeoutSpy = jest.spyOn(globalThis, 'setTimeout');
161
+
162
+ try {
163
+ const { yielded, dispatched } = await runStream(
164
+ [
165
+ {
166
+ contentBlockDelta: {
167
+ contentBlockIndex: 0,
168
+ delta: { text: 'hello' },
169
+ },
170
+ },
171
+ {
172
+ contentBlockDelta: {
173
+ contentBlockIndex: 0,
174
+ delta: { text: ' world' },
175
+ },
176
+ },
177
+ ],
178
+ { _lc_stream_delay: 35 }
179
+ );
180
+
181
+ expect(yielded.map((m) => m.content)).toEqual(['hello', ' world']);
182
+ expect(dispatched.map((m) => m.content)).toEqual(['hello', ' world']);
183
+
184
+ const timeoutDelays = setTimeoutSpy.mock.calls
185
+ .map(([, delay]) => delay)
186
+ .filter((delay): delay is number => typeof delay === 'number');
187
+ expect(timeoutDelays.some((delay) => delay > 0 && delay <= 35)).toBe(
188
+ true
189
+ );
190
+ } finally {
191
+ setTimeoutSpy.mockRestore();
192
+ }
193
+ });
194
+
195
+ test('splits large text deltas at Anthropic stream boundaries', async () => {
196
+ const { yielded, dispatched } = await runStream(
197
+ [
198
+ {
199
+ contentBlockDelta: {
200
+ contentBlockIndex: 0,
201
+ delta: { text: 'alpha beta gamma' },
202
+ },
203
+ },
204
+ ],
205
+ { _lc_stream_delay: 1 }
206
+ );
207
+
208
+ expect(yielded.map((m) => m.content)).toEqual(['alpha ', 'beta ', 'gamma']);
209
+ expect(dispatched.map((m) => m.content)).toEqual([
210
+ 'alpha ',
211
+ 'beta ',
212
+ 'gamma',
213
+ ]);
214
+ });
215
+
216
+ test('paces visible reasoning deltas with configured stream delay', async () => {
217
+ const setTimeoutSpy = jest.spyOn(globalThis, 'setTimeout');
218
+
219
+ try {
220
+ const { yielded, dispatched } = await runStream(
221
+ [
222
+ {
223
+ contentBlockDelta: {
224
+ contentBlockIndex: 0,
225
+ delta: { reasoningContent: { text: 'One' } },
226
+ },
227
+ },
228
+ {
229
+ contentBlockDelta: {
230
+ contentBlockIndex: 0,
231
+ delta: { reasoningContent: { text: 'Two' } },
232
+ },
233
+ },
234
+ ],
235
+ { _lc_stream_delay: 35 }
236
+ );
237
+
238
+ expect(yielded.map((m) => m.additional_kwargs.reasoning_content)).toEqual(
239
+ ['One', 'Two']
240
+ );
241
+ expect(
242
+ dispatched.map((m) => m.additional_kwargs.reasoning_content)
243
+ ).toEqual(['One', 'Two']);
244
+
245
+ const timeoutDelays = setTimeoutSpy.mock.calls
246
+ .map(([, delay]) => delay)
247
+ .filter((delay): delay is number => typeof delay === 'number');
248
+ expect(timeoutDelays.some((delay) => delay > 0 && delay <= 35)).toBe(
249
+ true
250
+ );
251
+ } finally {
252
+ setTimeoutSpy.mockRestore();
253
+ }
254
+ });
158
255
  });