UNPKG

@langchain/langgraph-sdk

Version:

Client library for interacting with the LangGraph API

566 lines (565 loc) • 23.5 kB
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