@langchain/langgraph-sdk
Version:
Client library for interacting with the LangGraph API
226 lines (225 loc) • 10.3 kB
JavaScript
//#region src/stream/message-reconciliation.ts
/**
* Merge an authoritative `values.messages` snapshot with the current streamed
* message projection.
*
* Values remain authoritative for ordering and removals. Streamed messages
* remain authoritative for in-flight content that has moved past this
* snapshot. When the server echoes a same-id message with equal content
* plus enrichment (tool calls, `additional_kwargs`, …), values win.
* Stream-only messages are preserved until they either appear in values
* or are known to have been removed.
*/
function reconcileMessagesFromValues({ valueMessages, currentMessages, currentIndexById, previousValueMessageIds, streamedMessageIds, preferValuesMessage, addOnly }) {
const valueMessageIds = /* @__PURE__ */ new Set();
const merged = [];
for (const valuesMessage of valueMessages) {
const id = normalizedMessageId(valuesMessage);
if (id == null) {
merged.push(valuesMessage);
continue;
}
valueMessageIds.add(id);
const streamIdx = currentIndexById.get(id);
const streamedMessage = streamIdx != null && (streamedMessageIds == null || streamedMessageIds.has(id)) ? currentMessages[streamIdx] : void 0;
if (streamedMessage != null && preferValuesMessage?.(valuesMessage, streamedMessage) !== true) merged.push(streamedMessage);
else merged.push(valuesMessage);
}
for (const existing of currentMessages) {
const id = normalizedMessageId(existing);
if (id == null) continue;
if (valueMessageIds.has(id)) continue;
if (!addOnly && previousValueMessageIds.has(id)) continue;
if (streamedMessageIds != null && !streamedMessageIds.has(id)) continue;
merged.push(existing);
}
return {
messages: messagesEqualList(currentMessages, merged) ? currentMessages : merged,
valueMessageIds
};
}
/**
* Build a position index for keyed messages.
*/
function buildMessageIndex(messages) {
const index = /* @__PURE__ */ new Map();
messages.forEach((message, idx) => {
const id = normalizedMessageId(message);
if (id != null) index.set(id, idx);
});
return index;
}
function shouldPreferValuesMessage(valuesMessage, streamedMessage, options) {
if (shouldPreferValuesMessageForToolCalls(valuesMessage, streamedMessage)) return true;
if (!jsonishEqual(valuesMessage.content, streamedMessage.content)) return false;
return valuesAddsMetadataEnrichment(valuesMessage, streamedMessage, options);
}
/** Metadata fields that a values echo may enrich on a same-id message. */
const METADATA_ENRICHMENT_KEYS = [
"additional_kwargs",
"response_metadata",
"usage_metadata"
];
/**
* True when values adds or mutates metadata without stripping richer
* fields already present on the current copy.
*
* Covers both key additions (optimistic human gaining
* `additional_kwargs.attachments`) and — when
* `allowInPlaceMutations` is true — in-place nested mutations of
* existing keys (HITL card `status: accepted → done`) as long as every
* nested key on the current field is retained.
*/
function valuesAddsMetadataEnrichment(valuesMessage, streamedMessage, options) {
const allowInPlaceMutations = options?.allowInPlaceMutations !== false;
const valuesRecord = valuesMessage;
const streamedRecord = streamedMessage;
let enriches = false;
for (const key of METADATA_ENRICHMENT_KEYS) {
const valuesField = valuesRecord[key];
const streamedField = streamedRecord[key];
if (jsonishEqual(valuesField, streamedField)) continue;
const valuesEmpty = isEmptyMeta(valuesField);
const streamedEmpty = isEmptyMeta(streamedField);
if (valuesEmpty && !streamedEmpty) return false;
if (!valuesEmpty && streamedEmpty) {
enriches = true;
continue;
}
if (allowInPlaceMutations) {
if (retainsAllNestedKeys(valuesField, streamedField)) {
enriches = true;
continue;
}
return false;
}
if (isObjectShallowSuperset(valuesField, streamedField)) {
enriches = true;
continue;
}
return false;
}
return enriches;
}
function isEmptyMeta(value) {
if (value == null) return true;
if (typeof value === "string") return value.length === 0;
if (Array.isArray(value)) return value.length === 0;
if (typeof value === "object") return Object.keys(value).length === 0;
return false;
}
/**
* True when `candidate` is a shallow object superset of `baseline`:
* every baseline key is present with an equal value, and candidate has
* at least one extra key.
*/
function isObjectShallowSuperset(candidate, baseline) {
if (candidate == null || baseline == null || typeof candidate !== "object" || typeof baseline !== "object" || Array.isArray(candidate) || Array.isArray(baseline)) return false;
const candidateRecord = candidate;
const baselineRecord = baseline;
const baselineKeys = Object.keys(baselineRecord);
if (Object.keys(candidateRecord).length <= baselineKeys.length) return false;
for (const key of baselineKeys) {
if (!Object.prototype.hasOwnProperty.call(candidateRecord, key)) return false;
if (!jsonishEqual(candidateRecord[key], baselineRecord[key])) return false;
}
return true;
}
/**
* True when `candidate` retains every nested key present on `baseline`.
* Nested leaf values may differ (in-place mutations); missing keys mean
* preferring candidate would erase richer current metadata.
*/
function retainsAllNestedKeys(candidate, baseline) {
if (baseline == null || typeof baseline !== "object") return true;
if (candidate == null || typeof candidate !== "object" || Array.isArray(baseline) !== Array.isArray(candidate)) return false;
if (Array.isArray(baseline)) {
const candidateArr = candidate;
const baselineArr = baseline;
if (candidateArr.length < baselineArr.length) return false;
for (let i = 0; i < baselineArr.length; i += 1) if (!retainsAllNestedKeys(candidateArr[i], baselineArr[i])) return false;
return true;
}
const candidateRecord = candidate;
const baselineRecord = baseline;
for (const key of Object.keys(baselineRecord)) {
if (!Object.prototype.hasOwnProperty.call(candidateRecord, key)) return false;
if (!retainsAllNestedKeys(candidateRecord[key], baselineRecord[key])) return false;
}
return true;
}
/**
* Decide whether a values message carries tool-call data missing from the
* streamed message.
*/
function shouldPreferValuesMessageForToolCalls(valuesMessage, streamedMessage) {
const valuesToolCalls = getMessageToolCalls(valuesMessage);
if (valuesToolCalls.length === 0) return false;
const streamedToolCalls = getMessageToolCalls(streamedMessage);
if (streamedToolCalls.length < valuesToolCalls.length) return true;
const streamedIds = new Set(streamedToolCalls.map((toolCall) => toolCall.id).filter((id) => typeof id === "string" && id.length > 0));
if (valuesToolCalls.some((toolCall) => {
return typeof toolCall.id === "string" && !streamedIds.has(toolCall.id);
})) return true;
return valuesToolCalls.some((valuesToolCall) => {
const streamedToolCall = streamedToolCalls.find((candidate) => typeof valuesToolCall.id === "string" && candidate.id === valuesToolCall.id);
return streamedToolCall != null && hasMeaningfulArgs(valuesToolCall.args) && !jsonishEqual(valuesToolCall.args, streamedToolCall.args);
});
}
function hasMeaningfulArgs(args) {
if (args == null) return false;
if (typeof args === "string") return args.length > 0;
if (typeof args === "object") return Object.keys(args).length > 0;
return true;
}
function messagesEqualList(previous, next) {
if (previous === next) return true;
if (previous.length !== next.length) return false;
for (let i = 0; i < previous.length; i += 1) if (!messagesEqual(previous[i], next[i])) return false;
return true;
}
function messagesEqual(previous, next) {
if (previous === next) return true;
if (previous == null || next == null) return false;
const previousRecord = previous;
const nextRecord = next;
const previousType = typeof previous.getType === "function" ? previous.getType() : previousRecord.type;
const nextType = typeof next.getType === "function" ? next.getType() : nextRecord.type;
return previous.id === next.id && previousType === nextType && jsonishEqual(previous.content, next.content) && previousRecord.tool_call_id === nextRecord.tool_call_id && previousRecord.status === nextRecord.status && jsonishEqual(previousRecord.additional_kwargs, nextRecord.additional_kwargs) && jsonishEqual(previousRecord.response_metadata, nextRecord.response_metadata) && jsonishEqual(previousRecord.tool_calls, nextRecord.tool_calls) && jsonishEqual(previousRecord.tool_call_chunks, nextRecord.tool_call_chunks) && jsonishEqual(previousRecord.usage_metadata, nextRecord.usage_metadata);
}
function normalizedMessageId(message) {
return typeof message.id === "string" && message.id.length > 0 ? message.id : void 0;
}
function getMessageToolCalls(message) {
const raw = message.tool_calls;
if (!Array.isArray(raw)) return [];
return raw.filter((toolCall) => toolCall != null && typeof toolCall === "object");
}
function jsonishEqual(previous, next) {
return jsonishEqualAtDepth(previous, next, 0);
}
function jsonishEqualAtDepth(previous, next, depth) {
if (Object.is(previous, next)) return true;
if (previous == null || next == null) return false;
if (typeof previous !== "object" || typeof next !== "object") return false;
if (depth >= 4) return false;
if (Array.isArray(previous) || Array.isArray(next)) {
if (!Array.isArray(previous) || !Array.isArray(next)) return false;
if (previous.length !== next.length) return false;
for (let i = 0; i < previous.length; i += 1) if (!jsonishEqualAtDepth(previous[i], next[i], depth + 1)) return false;
return true;
}
const previousRecord = previous;
const nextRecord = next;
const previousKeys = Object.keys(previousRecord).filter((key) => typeof previousRecord[key] !== "function");
const nextKeys = Object.keys(nextRecord).filter((key) => typeof nextRecord[key] !== "function");
if (previousKeys.length !== nextKeys.length) return false;
for (const key of previousKeys) {
if (!Object.prototype.hasOwnProperty.call(nextRecord, key)) return false;
if (!jsonishEqualAtDepth(previousRecord[key], nextRecord[key], depth + 1)) return false;
}
return true;
}
//#endregion
export { buildMessageIndex, messagesEqual, reconcileMessagesFromValues, shouldPreferValuesMessage };
//# sourceMappingURL=message-reconciliation.js.map