@langchain/langgraph-sdk
Version:
Client library for interacting with the LangGraph API
566 lines (565 loc) • 23.5 kB
JavaScript
const require_messages = require("../client/stream/messages.cjs");
const require_namespace = require("./namespace.cjs");
const require_assembled_to_message = require("./assembled-to-message.cjs");
const require_message_reconciliation = require("./message-reconciliation.cjs");
//#region src/stream/root-message-projection.ts
/**
* Root-namespace message projection. Owns the merge between the
* `messages` (streamed deltas) and `values` (authoritative
* snapshots) channels for the root namespace.
*
* @typeParam StateType - Root state shape; the messages array is read
* from `values[messagesKey]`.
* @typeParam InterruptType - Shape of root protocol interrupts (forwarded
* into `RootSnapshot` updates).
*/
var RootMessageProjection = class {
/**
* Key inside `values` that holds the message array. Defaults to
* `"messages"` in the controller; configurable for state graphs
* that surface messages under a different slot.
*/
#messagesKey;
/** Root snapshot store written to on every merge. */
#store;
/**
* Stateful chunk assembler for in-flight messages. Reset (via a
* fresh instance) on every {@link reset} so a new thread starts
* with no half-built messages from the previous one.
*/
#assembler = new require_messages.MessageAssembler();
/**
* `messageId → role/toolCallId` captured from `message-start` events.
* The assembler's intermediate output drops these fields, so we cache
* them at start-time and reapply when projecting to a `BaseMessage`.
*/
#roles = /* @__PURE__ */ new Map();
/**
* `messageId → position in #store.messages` for fast in-place
* updates as deltas arrive. Rebuilt on every full reconciliation
* driven by a `values` event.
*/
#indexById = /* @__PURE__ */ new Map();
/**
* Ids observed in the most recent `values.messages` snapshot.
* Reconciliation uses this to detect server-side removals: a
* previously-seen id missing from the next snapshot means it was
* removed by the server (and should drop from the projection).
*/
#valuesMessageIds = /* @__PURE__ */ new Set();
/**
* `namespaceKey → tool_call_id` captured from root `tool-started`
* events. Used as a fallback when a tool-role `message-start` is
* missing its `tool_call_id` field.
*/
#toolCallIdByNamespace = /* @__PURE__ */ new Map();
/**
* Coalescing buffer for store writes. {@link handleMessage} and
* {@link applyValues} stage their computed `messages` / `values`
* here instead of calling `store.setState` per event. A single
* `setTimeout(0)` flush commits them in one `setState`, so a
* burst of SSE events draining as a microtask chain becomes one
* store notification at the next macrotask boundary.
*
* `null` means "no staged write" — once a flush settles, the
* slots are cleared so the next call starts from the latest
* committed store snapshot.
*/
#pendingMessages = null;
#pendingValues = null;
#flushScheduled = false;
/**
* Highest checkpoint `step` whose `values` snapshot has been applied.
* Seeded by {@link StreamController.hydrate} from `getState()` and
* advanced by live `values` events. A snapshot arriving with a lower
* step is an older checkpoint replayed by the content pump on
* reconnect; it is reconciled in add-only mode so it cannot remove
* the seeded message tail (the final assistant turn). `undefined`
* until the first step-bearing snapshot, where the legacy
* remove-on-absence behavior is preserved.
*/
#maxStep = void 0;
/**
* Message ids seeded as complete-and-final from an idle thread's
* `getState()` snapshot. An idle thread defers its root SSE pump, and
* the first `submit()` brings it up — at which point the transport
* replays the finished run from `seq=0`. Unlike the `values` channel
* (guarded by {@link #maxStep}), `messages`-channel deltas carry no
* step, so that replay would otherwise rebuild each already-complete
* message from an empty `message-start` and re-stream the whole turn
* token-by-token, clobbering the seeded tail (a visible "messages
* replay" on the first submit). Deltas for a sealed id are dropped in
* {@link handleMessage}. The seal is lifted once a checkpoint advances
* strictly past {@link #sealStep} (see {@link applyValues}) or on
* thread rebind ({@link reset}). New ids from the next run are never
* sealed, so they stream normally.
*/
#sealedMessageIds = /* @__PURE__ */ new Set();
/**
* High-water {@link #maxStep} captured when {@link sealMessageIds} ran,
* i.e. the seed checkpoint's step (or `undefined` when `getState()`
* carried no `metadata.step`). It is the boundary between the replayed
* idle history (steps `<= #sealStep`, emitted by the deferred pump's
* `seq=0` replay) and the new run (steps `> #sealStep`); only a
* checkpoint strictly past it lifts the seal. Without this boundary the
* replayed old-run checkpoints — which themselves carry increasing
* steps — would advance {@link #maxStep} and lift the seal mid-replay,
* reopening the clobber. When the seed step is unknown the boundary
* stays `undefined` and the seal holds until {@link reset}; the
* `values` channel (which ignores the seal) still reconciles any
* genuine change to a sealed id, only its streamed deltas are dropped.
*/
#sealStep = void 0;
/**
* Streamed ids that no `values` snapshot has carried yet. Dropped when
* the run ends or the next one starts (see {@link applyLifecycle}).
*/
#openRunStreamIds = /* @__PURE__ */ new Set();
/** Subset of {@link #openRunStreamIds} that reached `message-finish`. */
#finishedStreamIds = /* @__PURE__ */ new Set();
/**
* Ids dropped by {@link closeRun}. Streamed events for them are ignored
* until {@link reset}, so a replayed `message-start` can't rebuild a
* dropped message. A `values` snapshot that carries one still restores
* it.
*/
#droppedRunIds = /* @__PURE__ */ new Set();
/**
* @param params.messagesKey - Key inside `values` that holds the
* message array.
* @param params.store - Root snapshot store to mutate.
*/
constructor(params) {
this.#messagesKey = params.messagesKey;
this.#store = params.store;
}
/**
* Drop all per-thread state. Called by the controller on thread
* rebind / dispose so a swap doesn't surface stale messages.
*/
reset() {
this.#assembler = new require_messages.MessageAssembler();
this.#roles.clear();
this.#indexById.clear();
this.#valuesMessageIds = /* @__PURE__ */ new Set();
this.#toolCallIdByNamespace.clear();
this.#pendingMessages = null;
this.#pendingValues = null;
this.#flushScheduled = false;
this.#maxStep = void 0;
this.#sealedMessageIds.clear();
this.#sealStep = void 0;
this.#openRunStreamIds.clear();
this.#finishedStreamIds.clear();
this.#droppedRunIds.clear();
}
/**
* Seal message ids so the streamed `messages` channel cannot downgrade
* them to partial re-streams. Called by {@link StreamController.hydrate}
* after seeding an idle thread, whose deferred pump replays the finished
* run from `seq=0` on the first submit.
*
* Captures the current {@link #maxStep} as the lift boundary
* ({@link #sealStep}). The seal is applied immediately after the seed's
* `getState()` snapshot is reconciled, so `#maxStep` here is the seed
* step (or `undefined` when `getState()` carried no `metadata.step`).
* The seal is lifted once a checkpoint advances strictly past that
* boundary (see {@link applyValues}) or on thread rebind
* ({@link reset}).
*
* @param ids - Complete message ids from the idle `getState()` seed.
*/
sealMessageIds(ids) {
for (const id of ids) this.#sealedMessageIds.add(id);
if (this.#sealStep == null) this.#sealStep = this.#maxStep;
}
/**
* Record a `namespace → tool_call_id` mapping captured from a root
* `tool-started` event.
*
* The companion tool-role `message-start` event may not carry a
* `tool_call_id`, so we fall back to the most recent value recorded
* here for the same namespace.
*
* @param namespace - Event namespace from the `tool-started` event.
* @param toolCallId - Tool call id from the same event.
*/
recordToolCallNamespace(namespace, toolCallId) {
this.#toolCallIdByNamespace.set(require_namespace.namespaceKey(namespace), toolCallId);
}
/**
* Apply a `messages` channel event to the projection.
*
* Captures role/tool metadata on `message-start`, feeds the chunk
* to the assembler, projects the assembled output to a
* {@link BaseMessage}, and either appends or in-place updates the
* pending messages buffer based on whether the id was seen before.
*
* @param event - The `messages` channel event to consume.
*/
handleMessage(event) {
const data = event.params.data;
if (data.event === "message-start") {
const startData = data;
const role = startData.role ?? "ai";
const extendedRole = startData.role ?? role;
let toolCallId = startData.tool_call_id;
if (extendedRole === "tool" && toolCallId == null) {
const messageId = startData.id;
if (messageId != null) {
const match = /-tool-(.+)$/.exec(messageId);
if (match != null) toolCallId = match[1];
}
if (toolCallId == null) toolCallId = this.#toolCallIdByNamespace.get(require_namespace.namespaceKey(event.params.namespace));
}
if (startData.id != null) this.#roles.set(startData.id, {
role: extendedRole,
toolCallId
});
}
const update = this.#assembler.consume(event);
if (update == null) return;
const id = update.message.id;
if (id == null) return;
if (this.#sealedMessageIds.has(id)) return;
if (this.#droppedRunIds.has(id)) return;
if (!this.#valuesMessageIds.has(id)) this.#openRunStreamIds.add(id);
if (update.kind === "message-finish") this.#finishedStreamIds.add(id);
const captured = this.#roles.get(id) ?? { role: "ai" };
const base = require_assembled_to_message.assembledMessageToBaseMessage(update.message, captured.role, { toolCallId: captured.toolCallId });
const baselineMessages = this.#pendingMessages ?? this.#store.getSnapshot().messages;
const existingIdx = this.#indexById.get(id);
let messages;
if (existingIdx == null) {
this.#indexById.set(id, baselineMessages.length);
messages = [...baselineMessages, base];
} else if (require_message_reconciliation.messagesEqual(baselineMessages[existingIdx], base)) return;
else {
messages = baselineMessages.slice();
messages[existingIdx] = base;
}
const baselineValues = this.#pendingValues ?? this.#store.getSnapshot().values;
const values = syncMessagesIntoValues(baselineValues, this.#messagesKey, messages);
this.#pendingMessages = messages;
if (values !== baselineValues) this.#pendingValues = values;
this.#scheduleFlush();
}
/**
* Reconcile a full `values` snapshot into the projection.
*
* Delegates the merge to {@link reconcileMessagesFromValues}:
* values stays authoritative for ordering, removals, and same-id
* metadata enrichment, while streamed in-flight messages keep
* content that has moved past the snapshot. Empty messages just
* refresh the values blob.
*
* Rebuilds {@link #indexById} after the merge so subsequent delta
* applications target the new positions.
*
* @param nextValues - Full values snapshot from the `values` event.
* @param nextMessages - The messages array extracted from
* `values[messagesKey]` and coerced to `BaseMessage` instances.
* @param opts.step - Checkpoint superstep for this snapshot, when
* known. A snapshot whose step is below the highest applied step is
* treated as a stale reconnect replay and reconciled add-only.
*/
applyValues(nextValues, nextMessages, opts) {
const baselineSnapshot = this.#store.getSnapshot();
const baselineMessages = this.#pendingMessages ?? baselineSnapshot.messages;
const baselineValues = this.#pendingValues ?? baselineSnapshot.values;
const step = opts?.step;
const addOnly = step != null && this.#maxStep != null && step < this.#maxStep;
if (step != null && (this.#maxStep == null || step > this.#maxStep)) this.#maxStep = step;
if (this.#sealedMessageIds.size > 0 && step != null && this.#sealStep != null && step > this.#sealStep) this.#sealedMessageIds.clear();
if (nextMessages.length === 0) {
if (stateValuesShallowEqual(baselineValues, nextValues, this.#messagesKey)) return;
this.#pendingValues = syncMessagesIntoValues(nextValues, this.#messagesKey, baselineMessages);
this.#scheduleFlush();
return;
}
const reconciliation = require_message_reconciliation.reconcileMessagesFromValues({
valueMessages: nextMessages,
currentMessages: baselineMessages,
currentIndexById: this.#indexById,
previousValueMessageIds: this.#valuesMessageIds,
preferValuesMessage: (valuesMessage, streamedMessage) => require_message_reconciliation.shouldPreferValuesMessage(valuesMessage, streamedMessage, { allowInPlaceMutations: !addOnly }),
addOnly
});
if (!addOnly) {
this.#valuesMessageIds = reconciliation.valueMessageIds;
for (const id of reconciliation.valueMessageIds) {
this.#openRunStreamIds.delete(id);
this.#finishedStreamIds.delete(id);
}
}
const messages = reconciliation.messages;
const values = {
...nextValues,
[this.#messagesKey]: messages
};
if (messages === baselineMessages && stateValuesShallowEqual(baselineValues, values, this.#messagesKey)) return;
this.#indexById.clear();
for (const [id, idx] of require_message_reconciliation.buildMessageIndex(messages)) this.#indexById.set(id, idx);
this.#pendingMessages = messages;
this.#pendingValues = values;
this.#scheduleFlush();
}
/**
* Append messages applied optimistically by a local `submit()`,
* keyed by id so the eventual server echo reconciles cleanly.
*
* Unlike {@link applyValues}, the supplied messages are *not* treated
* as an authoritative ordered snapshot: they are appended to the end
* of the current projection (or replaced in place when the id already
* exists), preserving prior history ordering. When the server later
* emits a `values` snapshot containing the same ids,
* {@link applyValues} → {@link reconcileMessagesFromValues} takes over
* (server ordering wins; the echoed message replaces the optimistic
* one when content matches *and* values adds or mutates nested
* metadata without dropping keys the optimistic copy already has, so
* server-authored `additional_kwargs` / in-place
* `response_metadata` updates are visible without waiting for
* hydration — lagging/poorer snapshots do not strip richer current
* metadata).
* In-flight streamed content still wins when it has moved past the
* snapshot.
*
* Non-message input keys are shallow-merged into `values` via
* `extraValues`; they are dropped/overwritten automatically by the
* first server `values` event (which rebuilds `values` from the
* server snapshot), or rolled back via {@link restoreValueKeys} when
* the run fails before any echo.
*
* @param messages - Optimistic messages (already coerced to
* `BaseMessage` instances, each carrying a stable id).
* @param extraValues - Non-message input keys to shallow-merge into
* `values`.
* @param options - When `sync` is true the staged write is
* committed to the store *synchronously* instead of being coalesced
* onto the next macrotask. Used for discrete, user-initiated
* optimistic writes (`submit()` / `respond()` / `respondAll()`):
* committing in the same tick as the triggering event lets the
* framework render the optimistic message in the *same* commit as
* any local state the caller flipped alongside it (e.g. a HITL form
* hiding its inputs), so the pushed card never blinks out for the
* one-macrotask window before the flush lands. Streaming writes
* (`handleMessage` / `applyValues`) keep the default macrotask
* coalescing, which is what tames high-frequency SSE bursts.
*/
appendOptimistic(messages, extraValues, options) {
let working = this.#pendingMessages ?? this.#store.getSnapshot().messages;
let mutated = false;
for (const message of messages) {
const id = message.id;
if (id == null) continue;
const existingIdx = this.#indexById.get(id);
if (existingIdx == null) {
if (!mutated) {
working = working.slice();
mutated = true;
}
this.#indexById.set(id, working.length);
working.push(message);
} else if (!require_message_reconciliation.messagesEqual(working[existingIdx], message)) {
if (!mutated) {
working = working.slice();
mutated = true;
}
working[existingIdx] = message;
}
}
const baselineValues = this.#pendingValues ?? this.#store.getSnapshot().values;
let values = baselineValues;
if (extraValues != null && Object.keys(extraValues).length > 0) values = {
...baselineValues,
...extraValues
};
values = syncMessagesIntoValues(values, this.#messagesKey, working);
if (!mutated && values === baselineValues) return;
this.#pendingMessages = working;
if (values !== baselineValues) this.#pendingValues = values;
if (options?.sync) this.#flushPending();
else this.#scheduleFlush();
}
/**
* Apply a root lifecycle event. A terminal drops streams that never
* finished (the run was cut off); its value is ignored because a cancel
* and a pause for input both report `interrupted`. The next `running`
* drops everything earlier runs streamed but never committed.
*
* @returns Ids dropped by {@link closeRun} (empty if none).
*/
applyLifecycle(event) {
if (event === "running") return this.closeRun();
if (event === "completed" || event === "interrupted" || event === "failed") return this.closeRun({ unfinishedOnly: true });
return /* @__PURE__ */ new Set();
}
/**
* Drop never-checkpointed streamed messages and ignore further streamed
* events for their ids (see {@link #droppedRunIds}). A later snapshot
* still restores them.
*
* @param options.unfinishedOnly - Keep messages that reached
* `message-finish`.
* @returns The dropped ids.
*/
closeRun(options) {
this.#assembler.abandonActive();
const ids = /* @__PURE__ */ new Set();
for (const id of this.#openRunStreamIds) {
if (options?.unfinishedOnly && this.#finishedStreamIds.has(id)) continue;
ids.add(id);
}
for (const id of ids) {
this.#openRunStreamIds.delete(id);
this.#finishedStreamIds.delete(id);
}
if (ids.size === 0) return ids;
for (const id of ids) this.#droppedRunIds.add(id);
this.dropMessages(ids, options);
return ids;
}
/**
* Drop messages by id without disturbing the rest of the projection.
*
* @param ids - Message ids to remove.
* @param options - `sync` commits the write immediately.
*/
dropMessages(ids, options) {
if (ids.size === 0) return;
const baselineMessages = this.#pendingMessages ?? this.#store.getSnapshot().messages;
const next = baselineMessages.filter((m) => m.id == null || !ids.has(m.id));
if (next.length === baselineMessages.length) return;
this.#indexById.clear();
for (const [id, idx] of require_message_reconciliation.buildMessageIndex(next)) this.#indexById.set(id, idx);
const baselineValues = this.#pendingValues ?? this.#store.getSnapshot().values;
this.#pendingMessages = next;
this.#pendingValues = syncMessagesIntoValues(baselineValues, this.#messagesKey, next);
if (options?.sync) this.#flushPending();
else this.#scheduleFlush();
}
/**
* Restore (or delete) `values` keys that were optimistically merged
* by {@link appendOptimistic} but never echoed by the server — i.e.
* roll back non-message optimistic state when the run fails before
* any `values` event lands. Messages are left untouched (kept on
* failure per the optimistic contract).
*
* @param restore - Per-key pre-submit snapshot: when `hadKey` is
* false the key is deleted, otherwise it is reset to `prevValue`.
*/
restoreValueKeys(restore) {
if (restore.length === 0) return;
const next = { ...this.#pendingValues ?? this.#store.getSnapshot().values };
let changed = false;
for (const { key, hadKey, prevValue } of restore) {
if (key === this.#messagesKey) continue;
if (hadKey) {
if (!Object.is(next[key], prevValue)) {
next[key] = prevValue;
changed = true;
}
} else if (Object.prototype.hasOwnProperty.call(next, key)) {
delete next[key];
changed = true;
}
}
if (!changed) return;
this.#pendingValues = next;
this.#scheduleFlush();
}
/**
* Schedule a coalesced flush on the next macrotask. Idempotent
* within a tick — multiple `handleMessage` / `applyValues` calls
* before the flush fires collapse into one store write.
*
* `setTimeout(0)` is a macrotask: it runs after the current
* microtask chain drains, so a burst of SSE events processed by
* the controller's `for await` pump becomes one `store.setState`
* (and therefore one `useSyncExternalStore` notification).
*/
#scheduleFlush = () => {
if (this.#flushScheduled) return;
this.#flushScheduled = true;
setTimeout(this.#flushPending, 0);
};
/**
* Drain `#pendingMessages` / `#pendingValues` to the store in a
* single `setState` call.
*/
#flushPending = () => {
this.#flushScheduled = false;
const messages = this.#pendingMessages;
const values = this.#pendingValues;
this.#pendingMessages = null;
this.#pendingValues = null;
if (messages == null && values == null) return;
this.#store.setState((s) => {
if (messages == null) return values == null ? s : {
...s,
values
};
if (values == null) return {
...s,
messages
};
return {
...s,
messages,
values
};
});
};
};
/**
* Mirror a freshly-updated message list into `values[messagesKey]`.
*
* Returns the same `values` reference when the list is already
* equal-by-content so the caller can keep the existing snapshot
* identity (and avoid spurious `setSnapshot` notifications).
*/
function syncMessagesIntoValues(values, messagesKey, messages) {
const record = values;
const current = record[messagesKey];
if (Array.isArray(current) && messagesEqualList(current, messages)) return values;
return {
...record,
[messagesKey]: messages
};
}
/**
* True when two `BaseMessage` arrays carry the same per-message
* content (using {@link messagesEqual}).
*/
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 (!require_message_reconciliation.messagesEqual(previous[i], next[i])) return false;
return true;
}
/**
* Shallow-equal for `values` objects, *ignoring* the messages slot.
*
* The messages array is compared separately by the caller (via
* {@link messagesEqualList}) because both arrays contain class
* instances whose JSON representation is not stable across reads.
*/
function stateValuesShallowEqual(previous, next, messagesKey) {
if (previous === next) return true;
const previousRecord = previous;
const nextRecord = next;
const previousKeys = Object.keys(previousRecord);
const nextKeys = Object.keys(nextRecord);
if (previousKeys.length !== nextKeys.length) return false;
for (const key of previousKeys) {
if (!Object.prototype.hasOwnProperty.call(nextRecord, key)) return false;
const previousValue = previousRecord[key];
const nextValue = nextRecord[key];
if (key === messagesKey && Array.isArray(previousValue) && Array.isArray(nextValue)) continue;
if (!Object.is(previousValue, nextValue)) return false;
}
return true;
}
//#endregion
exports.RootMessageProjection = RootMessageProjection;
//# sourceMappingURL=root-message-projection.cjs.map