@ai-sdk/langchain 3.0.0-beta.17 → 3.0.0-beta.178

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.
package/src/utils.ts CHANGED
@@ -2,26 +2,30 @@ import {
2
2
  AIMessage,
3
3
  HumanMessage,
4
4
  ToolMessage,
5
- BaseMessage,
6
5
  AIMessageChunk,
7
- BaseMessageChunk,
8
- ToolCallChunk,
9
6
  type ContentBlock,
10
7
  type ToolCall,
8
+ type BaseMessage,
9
+ type BaseMessageChunk,
10
+ type ToolCallChunk,
11
11
  } from '@langchain/core/messages';
12
- import {
13
- type UIMessageChunk,
14
- type ToolResultPart,
15
- type AssistantContent,
16
- type UserContent,
12
+ import type {
13
+ UIMessageChunk,
14
+ ToolResultPart,
15
+ AssistantContent,
16
+ UserContent,
17
+ ProviderMetadata,
18
+ JSONValue,
17
19
  } from 'ai';
18
20
 
19
- import {
20
- type LangGraphEventState,
21
- type ReasoningContentBlock,
22
- type ThinkingContentBlock,
23
- type GPT5ReasoningOutput,
24
- type ImageGenerationOutput,
21
+ import type {
22
+ LangGraphEventState,
23
+ LangGraphMessageSeen,
24
+ ReasoningContentBlock,
25
+ ThinkingContentBlock,
26
+ GPT5ReasoningOutput,
27
+ ImageGenerationOutput,
28
+ NormalizedCitation,
25
29
  } from './types';
26
30
 
27
31
  /**
@@ -114,8 +118,8 @@ function getDefaultFilename(
114
118
  mediaType: string,
115
119
  prefix: string = 'file',
116
120
  ): string {
117
- const ext = mediaType.split('/')[1] || 'bin';
118
- return `${prefix}.${ext}`;
121
+ const fileExtension = mediaType.split('/')[1] || 'bin';
122
+ return `${prefix}.${fileExtension}`;
119
123
  }
120
124
 
121
125
  /**
@@ -220,13 +224,53 @@ export function convertUserContent(content: UserContent): HumanMessage {
220
224
  });
221
225
  }
222
226
  } else if (part.type === 'file') {
223
- const filePart = part as {
227
+ const rawFilePart = part as {
224
228
  type: 'file';
225
- data: string | Uint8Array | URL | ArrayBuffer;
229
+ data:
230
+ | string
231
+ | Uint8Array
232
+ | URL
233
+ | ArrayBuffer
234
+ | { type: 'data'; data: string | Uint8Array | ArrayBuffer }
235
+ | { type: 'url'; url: URL }
236
+ | { type: 'reference'; reference: Record<string, string> }
237
+ | { type: 'text'; text: string };
226
238
  mediaType: string;
227
239
  filename?: string;
228
240
  };
229
241
 
242
+ // Normalize tagged data shape into the legacy bare value this code expects.
243
+ const normalizedData: string | Uint8Array | URL | ArrayBuffer = (() => {
244
+ const data = rawFilePart.data;
245
+ if (
246
+ typeof data === 'object' &&
247
+ data !== null &&
248
+ !(data instanceof URL) &&
249
+ !(data instanceof Uint8Array) &&
250
+ !(data instanceof ArrayBuffer) &&
251
+ 'type' in data
252
+ ) {
253
+ switch (data.type) {
254
+ case 'data':
255
+ return data.data;
256
+ case 'url':
257
+ return data.url;
258
+ case 'text':
259
+ return data.text;
260
+ default:
261
+ return '';
262
+ }
263
+ }
264
+ return data as string | Uint8Array | URL | ArrayBuffer;
265
+ })();
266
+
267
+ const filePart = {
268
+ type: 'file' as const,
269
+ data: normalizedData,
270
+ mediaType: rawFilePart.mediaType,
271
+ filename: rawFilePart.filename,
272
+ };
273
+
230
274
  /**
231
275
  * Check if this is an image file - if so, use OpenAI's image_url format
232
276
  */
@@ -393,6 +437,7 @@ export function processModelChunk(
393
437
  /** Track the ID used for text-start to ensure text-end uses the same ID */
394
438
  textMessageId?: string | null;
395
439
  emittedImages?: Set<string>;
440
+ emittedSourceIds?: Set<string>;
396
441
  },
397
442
  controller: ReadableStreamDefaultController<UIMessageChunk>,
398
443
  ): void {
@@ -403,6 +448,10 @@ export function processModelChunk(
403
448
  state.emittedImages = new Set<string>();
404
449
  }
405
450
 
451
+ if (!state.emittedSourceIds) {
452
+ state.emittedSourceIds = new Set<string>();
453
+ }
454
+
406
455
  /**
407
456
  * Get the message ID from the chunk if available
408
457
  */
@@ -506,6 +555,16 @@ export function processModelChunk(
506
555
  id: state.textMessageId ?? state.messageId,
507
556
  });
508
557
  }
558
+
559
+ const citations = extractCitationsFromContentBlocks(chunk);
560
+ if (citations.length > 0) {
561
+ emitSourceChunks(
562
+ citations,
563
+ state.messageId,
564
+ state.emittedSourceIds,
565
+ controller,
566
+ );
567
+ }
509
568
  }
510
569
 
511
570
  /**
@@ -562,7 +621,8 @@ export function getMessageId(msg: unknown): string | undefined {
562
621
  /**
563
622
  * Checks if a message is an AI message chunk (works for both class instances and plain objects).
564
623
  * For class instances, only AIMessageChunk is matched (not AIMessage).
565
- * For plain objects from RemoteGraph API, matches type === 'ai'.
624
+ * For plain objects from RemoteGraph API, matches type === 'ai' (TypeScript langchain-core)
625
+ * or type === 'AIMessageChunk' (Python langchain-core).
566
626
  * For serialized LangChain messages, matches type === 'constructor' with AIMessageChunk in id path.
567
627
  *
568
628
  * @param msg - The message to check.
@@ -579,18 +639,25 @@ export function isAIMessageChunk(
579
639
  * Plain object from RemoteGraph API (not a LangChain class instance)
580
640
  */
581
641
  if (isPlainMessageObject(msg)) {
582
- const obj = msg as Record<string, unknown>;
642
+ const messageRecord = msg as Record<string, unknown>;
583
643
  /**
584
- * Direct type === 'ai' (RemoteGraph format)
644
+ * Direct type from RemoteGraph format. TypeScript langchain-core emits
645
+ * type === 'ai'; Python langchain-core emits type === 'AIMessageChunk'.
585
646
  */
586
- if ('type' in obj && obj.type === 'ai') return true;
647
+ if (
648
+ 'type' in messageRecord &&
649
+ (messageRecord.type === 'ai' || messageRecord.type === 'AIMessageChunk')
650
+ ) {
651
+ return true;
652
+ }
587
653
  /**
588
654
  * Serialized LangChain message format: { lc: 1, type: "constructor", id: ["...", "AIMessageChunk"], kwargs: {...} }
589
655
  */
590
656
  if (
591
- obj.type === 'constructor' &&
592
- Array.isArray(obj.id) &&
593
- (obj.id.includes('AIMessageChunk') || obj.id.includes('AIMessage'))
657
+ messageRecord.type === 'constructor' &&
658
+ Array.isArray(messageRecord.id) &&
659
+ (messageRecord.id.includes('AIMessageChunk') ||
660
+ messageRecord.id.includes('AIMessage'))
594
661
  ) {
595
662
  return true;
596
663
  }
@@ -612,18 +679,18 @@ export function isToolMessageType(
612
679
  * Plain object from RemoteGraph API (not a LangChain class instance)
613
680
  */
614
681
  if (isPlainMessageObject(msg)) {
615
- const obj = msg as Record<string, unknown>;
682
+ const messageRecord = msg as Record<string, unknown>;
616
683
  /**
617
684
  * Direct type === 'tool' (RemoteGraph format)
618
685
  */
619
- if ('type' in obj && obj.type === 'tool') return true;
686
+ if ('type' in messageRecord && messageRecord.type === 'tool') return true;
620
687
  /**
621
688
  * Serialized LangChain message format
622
689
  */
623
690
  if (
624
- obj.type === 'constructor' &&
625
- Array.isArray(obj.id) &&
626
- obj.id.includes('ToolMessage')
691
+ messageRecord.type === 'constructor' &&
692
+ Array.isArray(messageRecord.id) &&
693
+ messageRecord.id.includes('ToolMessage')
627
694
  ) {
628
695
  return true;
629
696
  }
@@ -916,6 +983,152 @@ export function extractReasoningFromValuesMessage(
916
983
  return undefined;
917
984
  }
918
985
 
986
+ export function isCitationContentBlock(
987
+ obj: unknown,
988
+ ): obj is ContentBlock.Citation {
989
+ return (
990
+ obj != null &&
991
+ typeof obj === 'object' &&
992
+ 'type' in obj &&
993
+ (obj as { type: unknown }).type === 'citation'
994
+ );
995
+ }
996
+
997
+ /**
998
+ * LangChain Core standardizes citations as `{ type: 'citation', ... }` entries in
999
+ * the `annotations` array of a text content block. The text extractors elsewhere
1000
+ * in this file only read the `text` field, so without this the citations would be
1001
+ * dropped instead of surfaced as AI SDK source parts.
1002
+ *
1003
+ * Handles both class instances and serialized LangChain message objects.
1004
+ */
1005
+ export function extractCitationsFromContentBlocks(
1006
+ msg: unknown,
1007
+ ): NormalizedCitation[] {
1008
+ if (msg == null || typeof msg !== 'object') return [];
1009
+
1010
+ const msgObj = msg as Record<string, unknown>;
1011
+ const kwargs =
1012
+ msgObj.kwargs && typeof msgObj.kwargs === 'object'
1013
+ ? (msgObj.kwargs as Record<string, unknown>)
1014
+ : msgObj;
1015
+
1016
+ // `contentBlocks` is the normalized view derived from `content`; reading both
1017
+ // would double-count annotations, so prefer it and only fall back to `content`.
1018
+ const contentBlocks = (kwargs as { contentBlocks?: unknown }).contentBlocks;
1019
+ const content = (kwargs as { content?: unknown }).content;
1020
+ const blockSources: unknown[] = Array.isArray(contentBlocks)
1021
+ ? contentBlocks
1022
+ : Array.isArray(content)
1023
+ ? content
1024
+ : [];
1025
+
1026
+ const citations: NormalizedCitation[] = [];
1027
+
1028
+ for (const block of blockSources) {
1029
+ if (block == null || typeof block !== 'object') continue;
1030
+
1031
+ const annotations = (block as { annotations?: unknown }).annotations;
1032
+ if (!Array.isArray(annotations)) continue;
1033
+
1034
+ for (const annotation of annotations) {
1035
+ if (!isCitationContentBlock(annotation)) continue;
1036
+
1037
+ citations.push({
1038
+ url: annotation.url,
1039
+ title: annotation.title,
1040
+ source: annotation.source,
1041
+ citedText: annotation.citedText,
1042
+ startIndex: annotation.startIndex,
1043
+ endIndex: annotation.endIndex,
1044
+ });
1045
+ }
1046
+ }
1047
+
1048
+ return citations;
1049
+ }
1050
+
1051
+ function buildSourceProviderMetadata(
1052
+ citation: NormalizedCitation,
1053
+ ): ProviderMetadata | undefined {
1054
+ const langchain: Record<string, JSONValue> = {};
1055
+
1056
+ if (typeof citation.citedText === 'string') {
1057
+ langchain.citedText = citation.citedText;
1058
+ }
1059
+ if (typeof citation.startIndex === 'number') {
1060
+ langchain.startIndex = citation.startIndex;
1061
+ }
1062
+ if (typeof citation.endIndex === 'number') {
1063
+ langchain.endIndex = citation.endIndex;
1064
+ }
1065
+ if (typeof citation.source === 'string') {
1066
+ langchain.source = citation.source;
1067
+ }
1068
+
1069
+ if (Object.keys(langchain).length === 0) {
1070
+ return undefined;
1071
+ }
1072
+
1073
+ return { langchain };
1074
+ }
1075
+
1076
+ /**
1077
+ * Emits AI SDK source chunks (`source-url` / `source-document`) for the given
1078
+ * citations.
1079
+ *
1080
+ * Citations with a `url` become `source-url` parts keyed by that url; the rest
1081
+ * become `source-document` parts. Citation metadata that the source parts cannot
1082
+ * represent natively (`citedText`, `startIndex`, `endIndex`, `source`) is preserved
1083
+ * under `providerMetadata.langchain`.
1084
+ *
1085
+ * `emittedSourceIds` dedupes across the LangGraph messages and values events, which
1086
+ * can each surface the same citation. URL-less citations are keyed by their content
1087
+ * rather than position, so a differing citation subset/order between those two events
1088
+ * does not cause an id collision.
1089
+ */
1090
+ export function emitSourceChunks(
1091
+ citations: NormalizedCitation[],
1092
+ messageId: string,
1093
+ emittedSourceIds: Set<string>,
1094
+ controller: ReadableStreamDefaultController<UIMessageChunk>,
1095
+ ): void {
1096
+ for (const citation of citations) {
1097
+ if (citation.url) {
1098
+ if (emittedSourceIds.has(citation.url)) continue;
1099
+ emittedSourceIds.add(citation.url);
1100
+
1101
+ const providerMetadata = buildSourceProviderMetadata(citation);
1102
+ controller.enqueue({
1103
+ type: 'source-url',
1104
+ sourceId: citation.url,
1105
+ url: citation.url,
1106
+ ...(citation.title ? { title: citation.title } : {}),
1107
+ ...(providerMetadata ? { providerMetadata } : {}),
1108
+ });
1109
+ continue;
1110
+ }
1111
+
1112
+ // A citation with no url and no human-readable label carries no information a
1113
+ // UI could render, so skip it rather than emit a placeholder source.
1114
+ const title = citation.title ?? citation.source;
1115
+ if (!title) continue;
1116
+
1117
+ const sourceId = `${messageId}:${title}:${citation.citedText ?? ''}:${citation.startIndex ?? ''}:${citation.endIndex ?? ''}`;
1118
+ if (emittedSourceIds.has(sourceId)) continue;
1119
+ emittedSourceIds.add(sourceId);
1120
+
1121
+ const providerMetadata = buildSourceProviderMetadata(citation);
1122
+ controller.enqueue({
1123
+ type: 'source-document',
1124
+ sourceId,
1125
+ mediaType: 'text/plain',
1126
+ title,
1127
+ ...(providerMetadata ? { providerMetadata } : {}),
1128
+ });
1129
+ }
1130
+ }
1131
+
919
1132
  /**
920
1133
  * Checks if an object is an image generation output
921
1134
  *
@@ -950,6 +1163,50 @@ export function extractImageOutputs(
950
1163
  return toolOutputs.filter(isImageGenerationOutput);
951
1164
  }
952
1165
 
1166
+ function formatToolError(error: unknown): string {
1167
+ if (error instanceof Error) return error.message;
1168
+ if (typeof error === 'string') return error;
1169
+
1170
+ try {
1171
+ const serialized = JSON.stringify(error);
1172
+ return serialized ?? String(error);
1173
+ } catch {
1174
+ return String(error);
1175
+ }
1176
+ }
1177
+
1178
+ /**
1179
+ * Returns per-message bookkeeping, creating it on first use.
1180
+ * Map keys keep remote-controlled message IDs like "__proto__" from touching Object.prototype.
1181
+ */
1182
+ function getOrCreateMessageSeen(
1183
+ messageSeen: LangGraphEventState['messageSeen'],
1184
+ msgId: string,
1185
+ ): LangGraphMessageSeen {
1186
+ let seen = messageSeen.get(msgId);
1187
+ if (!seen) {
1188
+ seen = {};
1189
+ messageSeen.set(msgId, seen);
1190
+ }
1191
+ return seen;
1192
+ }
1193
+
1194
+ /**
1195
+ * Returns the per-index tool call metadata for a message, creating it on first use.
1196
+ * Later streamed chunks use this to recover tool call IDs/names from earlier chunks.
1197
+ */
1198
+ function getOrCreateToolCallInfoByIndex(
1199
+ toolCallInfoByIndex: LangGraphEventState['toolCallInfoByIndex'],
1200
+ msgId: string,
1201
+ ): Map<number, { id: string; name: string }> {
1202
+ let toolCallInfo = toolCallInfoByIndex.get(msgId);
1203
+ if (!toolCallInfo) {
1204
+ toolCallInfo = new Map();
1205
+ toolCallInfoByIndex.set(msgId, toolCallInfo);
1206
+ }
1207
+ return toolCallInfo;
1208
+ }
1209
+
953
1210
  /**
954
1211
  * Processes a LangGraph event and emits UI message chunks.
955
1212
  *
@@ -971,6 +1228,7 @@ export function processLangGraphEvent(
971
1228
  messageReasoningIds,
972
1229
  toolCallInfoByIndex,
973
1230
  emittedToolCallsByKey,
1231
+ emittedToolInputs,
974
1232
  } = state;
975
1233
  const [type, data] = parseLangGraphEvent(event);
976
1234
 
@@ -1021,7 +1279,10 @@ export function processLangGraphEvent(
1021
1279
  if (!msgId) return;
1022
1280
 
1023
1281
  /**
1024
- * Track LangGraph step changes and emit start-step/finish-step events
1282
+ * Track LangGraph step changes and emit start-step/finish-step events.
1283
+ * Before emitting finish-step, close any open text/reasoning parts so
1284
+ * the client does not receive orphaned deltas after its
1285
+ * activeReasoningParts / activeTextParts have been cleared.
1025
1286
  */
1026
1287
  const langgraphStep =
1027
1288
  typeof metadata?.langgraph_step === 'number'
@@ -1029,6 +1290,17 @@ export function processLangGraphEvent(
1029
1290
  : null;
1030
1291
  if (langgraphStep !== null && langgraphStep !== state.currentStep) {
1031
1292
  if (state.currentStep !== null) {
1293
+ for (const [id, seen] of messageSeen) {
1294
+ if (seen.text) {
1295
+ controller.enqueue({ type: 'text-end', id });
1296
+ }
1297
+ if (seen.reasoning) {
1298
+ controller.enqueue({ type: 'reasoning-end', id });
1299
+ }
1300
+ messageSeen.delete(id);
1301
+ messageConcat.delete(id);
1302
+ messageReasoningIds.delete(id);
1303
+ }
1032
1304
  controller.enqueue({ type: 'finish-step' });
1033
1305
  }
1034
1306
  controller.enqueue({ type: 'start-step' });
@@ -1040,17 +1312,19 @@ export function processLangGraphEvent(
1040
1312
  * Note: Only works for actual class instances, not serialized messages
1041
1313
  */
1042
1314
  if (AIMessageChunk.isInstance(msg)) {
1043
- if (messageConcat[msgId]) {
1044
- messageConcat[msgId] = messageConcat[msgId].concat(
1045
- msg,
1046
- ) as AIMessageChunk;
1315
+ const existingMessage = messageConcat.get(msgId);
1316
+ if (existingMessage) {
1317
+ messageConcat.set(
1318
+ msgId,
1319
+ existingMessage.concat(msg) as AIMessageChunk,
1320
+ );
1047
1321
  } else {
1048
- messageConcat[msgId] = msg;
1322
+ messageConcat.set(msgId, msg);
1049
1323
  }
1050
1324
  }
1051
1325
 
1052
1326
  if (isAIMessageChunk(msg)) {
1053
- const concatChunk = messageConcat[msgId];
1327
+ const concatChunk = messageConcat.get(msgId);
1054
1328
 
1055
1329
  /**
1056
1330
  * Handle image generation outputs from additional_kwargs.tool_outputs
@@ -1103,29 +1377,34 @@ export function processLangGraphEvent(
1103
1377
  | undefined;
1104
1378
  if (toolCallChunks?.length) {
1105
1379
  for (const toolCallChunk of toolCallChunks) {
1106
- const idx = toolCallChunk.index ?? 0;
1380
+ const toolCallIndex = toolCallChunk.index ?? 0;
1107
1381
 
1108
1382
  /**
1109
1383
  * If this chunk has an id, store it for future lookups by index
1110
1384
  */
1111
1385
  if (toolCallChunk.id) {
1112
- toolCallInfoByIndex[msgId] ??= {};
1113
- toolCallInfoByIndex[msgId][idx] = {
1114
- id: toolCallChunk.id,
1115
- name:
1116
- toolCallChunk.name ||
1117
- concatChunk?.tool_call_chunks?.[idx]?.name ||
1118
- 'unknown',
1119
- };
1386
+ getOrCreateToolCallInfoByIndex(toolCallInfoByIndex, msgId).set(
1387
+ toolCallIndex,
1388
+ {
1389
+ id: toolCallChunk.id,
1390
+ name:
1391
+ toolCallChunk.name ||
1392
+ concatChunk?.tool_call_chunks?.[toolCallIndex]?.name ||
1393
+ 'unknown',
1394
+ },
1395
+ );
1120
1396
  }
1121
1397
 
1122
1398
  /**
1123
1399
  * Get the tool call ID from the chunk, stored info, or accumulated chunks
1124
1400
  */
1401
+ const storedToolCallInfo = toolCallInfoByIndex
1402
+ .get(msgId)
1403
+ ?.get(toolCallIndex);
1125
1404
  const toolCallId =
1126
1405
  toolCallChunk.id ||
1127
- toolCallInfoByIndex[msgId]?.[idx]?.id ||
1128
- concatChunk?.tool_call_chunks?.[idx]?.id;
1406
+ storedToolCallInfo?.id ||
1407
+ concatChunk?.tool_call_chunks?.[toolCallIndex]?.id;
1129
1408
 
1130
1409
  /**
1131
1410
  * Skip if we don't have a proper tool call ID - we'll handle it in values
@@ -1136,8 +1415,8 @@ export function processLangGraphEvent(
1136
1415
 
1137
1416
  const toolName =
1138
1417
  toolCallChunk.name ||
1139
- toolCallInfoByIndex[msgId]?.[idx]?.name ||
1140
- concatChunk?.tool_call_chunks?.[idx]?.name ||
1418
+ storedToolCallInfo?.name ||
1419
+ concatChunk?.tool_call_chunks?.[toolCallIndex]?.name ||
1141
1420
  'unknown';
1142
1421
 
1143
1422
  /**
@@ -1145,18 +1424,21 @@ export function processLangGraphEvent(
1145
1424
  * (even if args is empty - the first chunk often has empty args)
1146
1425
  * Set dynamic: true to enable HITL approval requests
1147
1426
  */
1148
- if (!messageSeen[msgId]?.tool?.[toolCallId]) {
1149
- controller.enqueue({
1150
- type: 'tool-input-start',
1151
- toolCallId: toolCallId,
1152
- toolName: toolName,
1153
- dynamic: true,
1154
- });
1427
+ const seen = messageSeen.get(msgId);
1428
+ if (!seen?.tool?.has(toolCallId)) {
1429
+ const updatedSeen = getOrCreateMessageSeen(messageSeen, msgId);
1430
+ updatedSeen.tool ??= new Set();
1431
+ updatedSeen.tool.add(toolCallId);
1155
1432
 
1156
- messageSeen[msgId] ??= {};
1157
- messageSeen[msgId].tool ??= {};
1158
- messageSeen[msgId].tool![toolCallId] = true;
1159
- emittedToolCalls.add(toolCallId);
1433
+ if (!emittedToolCalls.has(toolCallId)) {
1434
+ emittedToolCalls.add(toolCallId);
1435
+ controller.enqueue({
1436
+ type: 'tool-input-start',
1437
+ toolCallId: toolCallId,
1438
+ toolName: toolName,
1439
+ dynamic: true,
1440
+ });
1441
+ }
1160
1442
  }
1161
1443
 
1162
1444
  /**
@@ -1187,8 +1469,8 @@ export function processLangGraphEvent(
1187
1469
  // Capture reasoning ID when we first see it (even if no content yet)
1188
1470
  const chunkReasoningId = extractReasoningId(msg);
1189
1471
  if (chunkReasoningId) {
1190
- if (!messageReasoningIds[msgId]) {
1191
- messageReasoningIds[msgId] = chunkReasoningId;
1472
+ if (!messageReasoningIds.has(msgId)) {
1473
+ messageReasoningIds.set(msgId, chunkReasoningId);
1192
1474
  }
1193
1475
  // Immediately mark as emitted to prevent values from duplicating
1194
1476
  // This must happen as soon as we see the ID, before content arrives
@@ -1199,12 +1481,12 @@ export function processLangGraphEvent(
1199
1481
  if (reasoning) {
1200
1482
  // Use stored reasoning ID, or current chunk's ID, or fall back to message ID
1201
1483
  const reasoningId =
1202
- messageReasoningIds[msgId] ?? chunkReasoningId ?? msgId;
1484
+ messageReasoningIds.get(msgId) ?? chunkReasoningId ?? msgId;
1203
1485
 
1204
- if (!messageSeen[msgId]?.reasoning) {
1486
+ const seen = messageSeen.get(msgId);
1487
+ if (!seen?.reasoning) {
1205
1488
  controller.enqueue({ type: 'reasoning-start', id: msgId });
1206
- messageSeen[msgId] ??= {};
1207
- messageSeen[msgId].reasoning = true;
1489
+ getOrCreateMessageSeen(messageSeen, msgId).reasoning = true;
1208
1490
  }
1209
1491
 
1210
1492
  // Streaming chunks have delta text, emit directly without slicing
@@ -1222,10 +1504,10 @@ export function processLangGraphEvent(
1222
1504
  */
1223
1505
  const text = getMessageText(msg);
1224
1506
  if (text) {
1225
- if (!messageSeen[msgId]?.text) {
1507
+ const seen = messageSeen.get(msgId);
1508
+ if (!seen?.text) {
1226
1509
  controller.enqueue({ type: 'text-start', id: msgId });
1227
- messageSeen[msgId] ??= {};
1228
- messageSeen[msgId].text = true;
1510
+ getOrCreateMessageSeen(messageSeen, msgId).text = true;
1229
1511
  }
1230
1512
 
1231
1513
  controller.enqueue({
@@ -1234,6 +1516,16 @@ export function processLangGraphEvent(
1234
1516
  id: msgId,
1235
1517
  });
1236
1518
  }
1519
+
1520
+ const citations = extractCitationsFromContentBlocks(msg);
1521
+ if (citations.length > 0) {
1522
+ emitSourceChunks(
1523
+ citations,
1524
+ msgId,
1525
+ state.emittedSourceIds,
1526
+ controller,
1527
+ );
1528
+ }
1237
1529
  } else if (isToolMessageType(msg)) {
1238
1530
  // Handle both direct properties and serialized messages (kwargs)
1239
1531
  const msgObj = msg as unknown as Record<string, unknown>;
@@ -1272,31 +1564,122 @@ export function processLangGraphEvent(
1272
1564
  return;
1273
1565
  }
1274
1566
 
1567
+ case 'tools': {
1568
+ if (data == null || typeof data !== 'object' || Array.isArray(data)) {
1569
+ return;
1570
+ }
1571
+
1572
+ const payload = data as {
1573
+ event?: unknown;
1574
+ name?: unknown;
1575
+ input?: unknown;
1576
+ data?: unknown;
1577
+ output?: unknown;
1578
+ error?: unknown;
1579
+ toolCallId?: unknown;
1580
+ };
1581
+ const toolCallId =
1582
+ typeof payload.toolCallId === 'string' ? payload.toolCallId : undefined;
1583
+ const toolName =
1584
+ typeof payload.name === 'string' ? payload.name : 'unknown';
1585
+
1586
+ if (!toolCallId) return;
1587
+
1588
+ const ensureToolInputLifecycle = () => {
1589
+ if (!emittedToolCalls.has(toolCallId)) {
1590
+ emittedToolCalls.add(toolCallId);
1591
+ controller.enqueue({
1592
+ type: 'tool-input-start',
1593
+ toolCallId,
1594
+ toolName,
1595
+ dynamic: true,
1596
+ });
1597
+ }
1598
+
1599
+ if (!emittedToolInputs.has(toolCallId)) {
1600
+ emittedToolInputs.add(toolCallId);
1601
+ controller.enqueue({
1602
+ type: 'tool-input-available',
1603
+ toolCallId,
1604
+ toolName,
1605
+ input: payload.input,
1606
+ dynamic: true,
1607
+ });
1608
+ }
1609
+ };
1610
+
1611
+ switch (payload.event) {
1612
+ case 'on_tool_start': {
1613
+ const toolCallKey = `${toolName}:${JSON.stringify(payload.input)}`;
1614
+ emittedToolCallsByKey.set(toolCallKey, toolCallId);
1615
+
1616
+ ensureToolInputLifecycle();
1617
+ break;
1618
+ }
1619
+
1620
+ case 'on_tool_event': {
1621
+ ensureToolInputLifecycle();
1622
+ controller.enqueue({
1623
+ type: 'tool-output-available',
1624
+ toolCallId,
1625
+ output: payload.data,
1626
+ preliminary: true,
1627
+ });
1628
+ break;
1629
+ }
1630
+
1631
+ case 'on_tool_end': {
1632
+ ensureToolInputLifecycle();
1633
+ controller.enqueue({
1634
+ type: 'tool-output-available',
1635
+ toolCallId,
1636
+ output: payload.output,
1637
+ });
1638
+ break;
1639
+ }
1640
+
1641
+ case 'on_tool_error': {
1642
+ ensureToolInputLifecycle();
1643
+ controller.enqueue({
1644
+ type: 'tool-output-error',
1645
+ toolCallId,
1646
+ errorText: formatToolError(payload.error),
1647
+ });
1648
+ break;
1649
+ }
1650
+ }
1651
+
1652
+ return;
1653
+ }
1654
+
1275
1655
  case 'values': {
1276
1656
  /**
1277
1657
  * Finalize all pending message chunks
1278
1658
  */
1279
- for (const [id, seen] of Object.entries(messageSeen)) {
1659
+ for (const [id, seen] of messageSeen) {
1280
1660
  if (seen.text) controller.enqueue({ type: 'text-end', id });
1281
1661
  if (seen.tool) {
1282
- for (const [toolCallId, toolCallSeen] of Object.entries(seen.tool)) {
1283
- const concatMsg = messageConcat[id];
1662
+ for (const toolCallId of seen.tool) {
1663
+ const concatMsg = messageConcat.get(id);
1284
1664
  const toolCall = concatMsg?.tool_calls?.find(
1285
1665
  call => call.id === toolCallId,
1286
1666
  );
1287
1667
 
1288
- if (toolCallSeen && toolCall) {
1668
+ if (toolCall) {
1289
1669
  emittedToolCalls.add(toolCallId);
1290
1670
  // Store mapping for HITL interrupt lookup
1291
1671
  const toolCallKey = `${toolCall.name}:${JSON.stringify(toolCall.args)}`;
1292
1672
  emittedToolCallsByKey.set(toolCallKey, toolCallId);
1293
- controller.enqueue({
1294
- type: 'tool-input-available',
1295
- toolCallId,
1296
- toolName: toolCall.name,
1297
- input: toolCall.args,
1298
- dynamic: true,
1299
- });
1673
+ if (!emittedToolInputs.has(toolCallId)) {
1674
+ emittedToolInputs.add(toolCallId);
1675
+ controller.enqueue({
1676
+ type: 'tool-input-available',
1677
+ toolCallId,
1678
+ toolName: toolCall.name,
1679
+ input: toolCall.args,
1680
+ dynamic: true,
1681
+ });
1682
+ }
1300
1683
  }
1301
1684
  }
1302
1685
  }
@@ -1305,9 +1688,9 @@ export function processLangGraphEvent(
1305
1688
  controller.enqueue({ type: 'reasoning-end', id });
1306
1689
  }
1307
1690
 
1308
- delete messageSeen[id];
1309
- delete messageConcat[id];
1310
- delete messageReasoningIds[id];
1691
+ messageSeen.delete(id);
1692
+ messageConcat.delete(id);
1693
+ messageReasoningIds.delete(id);
1311
1694
  }
1312
1695
 
1313
1696
  /**
@@ -1367,21 +1750,25 @@ export function processLangGraphEvent(
1367
1750
  /**
1368
1751
  * For plain objects from RemoteGraph API or serialized LangChain messages
1369
1752
  */
1370
- const obj = msg as Record<string, unknown>;
1753
+ const messageRecord = msg as Record<string, unknown>;
1371
1754
 
1372
1755
  /**
1373
1756
  * Determine the data source (handle both direct and serialized formats)
1374
1757
  */
1375
1758
  const isSerializedFormat =
1376
- obj.type === 'constructor' &&
1377
- Array.isArray(obj.id) &&
1378
- ((obj.id as string[]).includes('AIMessageChunk') ||
1379
- (obj.id as string[]).includes('AIMessage'));
1759
+ messageRecord.type === 'constructor' &&
1760
+ Array.isArray(messageRecord.id) &&
1761
+ ((messageRecord.id as string[]).includes('AIMessageChunk') ||
1762
+ (messageRecord.id as string[]).includes('AIMessage'));
1380
1763
  const dataSource = isSerializedFormat
1381
- ? (obj.kwargs as Record<string, unknown>)
1382
- : obj;
1383
-
1384
- if (obj.type === 'ai' || isSerializedFormat) {
1764
+ ? (messageRecord.kwargs as Record<string, unknown>)
1765
+ : messageRecord;
1766
+
1767
+ if (
1768
+ messageRecord.type === 'ai' ||
1769
+ messageRecord.type === 'AIMessageChunk' ||
1770
+ isSerializedFormat
1771
+ ) {
1385
1772
  /**
1386
1773
  * Try tool_calls first (normalized format)
1387
1774
  */
@@ -1454,6 +1841,7 @@ export function processLangGraphEvent(
1454
1841
  toolName: toolCall.name,
1455
1842
  dynamic: true,
1456
1843
  });
1844
+ emittedToolInputs.add(toolCall.id);
1457
1845
  controller.enqueue({
1458
1846
  type: 'tool-input-available',
1459
1847
  toolCallId: toolCall.id,
@@ -1461,6 +1849,11 @@ export function processLangGraphEvent(
1461
1849
  input: toolCall.args,
1462
1850
  dynamic: true,
1463
1851
  });
1852
+ } else if (toolCall.id && emittedToolCalls.has(toolCall.id)) {
1853
+ // Register key mapping for tool calls already emitted via messages mode
1854
+ // so that __interrupt__ handling can match them by key
1855
+ const toolCallKey = `${toolCall.name}:${JSON.stringify(toolCall.args)}`;
1856
+ emittedToolCallsByKey.set(toolCallKey, toolCall.id);
1464
1857
  }
1465
1858
  }
1466
1859
  }
@@ -1476,7 +1869,7 @@ export function processLangGraphEvent(
1476
1869
  * AND tool_calls. We skip those to avoid duplicate reasoning entries.)
1477
1870
  */
1478
1871
  const reasoningId = extractReasoningId(msg);
1479
- const wasStreamedThisRequest = !!messageSeen[msgId];
1872
+ const wasStreamedThisRequest = messageSeen.has(msgId);
1480
1873
  const hasToolCalls = toolCalls && toolCalls.length > 0;
1481
1874
 
1482
1875
  /**
@@ -1509,6 +1902,16 @@ export function processLangGraphEvent(
1509
1902
  emittedReasoningIds.add(reasoningId);
1510
1903
  }
1511
1904
  }
1905
+
1906
+ const valuesCitations = extractCitationsFromContentBlocks(msg);
1907
+ if (valuesCitations.length > 0) {
1908
+ emitSourceChunks(
1909
+ valuesCitations,
1910
+ msgId,
1911
+ state.emittedSourceIds,
1912
+ controller,
1913
+ );
1914
+ }
1512
1915
  }
1513
1916
  }
1514
1917
  }
@@ -1572,6 +1975,7 @@ export function processLangGraphEvent(
1572
1975
  toolName,
1573
1976
  dynamic: true,
1574
1977
  });
1978
+ emittedToolInputs.add(toolCallId);
1575
1979
  controller.enqueue({
1576
1980
  type: 'tool-input-available',
1577
1981
  toolCallId,