@langchain/langgraph-api 1.2.2-rc.0 → 1.2.2

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 (42) hide show
  1. package/dist/src/api/assistants.d.mts +3 -0
  2. package/dist/src/api/assistants.mjs +194 -0
  3. package/dist/src/api/meta.d.mts +3 -0
  4. package/dist/src/api/meta.mjs +65 -0
  5. package/dist/src/api/protocol.d.mts +7 -0
  6. package/dist/src/api/protocol.mjs +157 -0
  7. package/dist/src/api/runs.d.mts +3 -0
  8. package/dist/src/api/runs.mjs +335 -0
  9. package/dist/src/api/store.d.mts +3 -0
  10. package/dist/src/api/store.mjs +111 -0
  11. package/dist/src/api/threads.d.mts +3 -0
  12. package/dist/src/api/threads.mjs +143 -0
  13. package/dist/src/graph/load.utils.d.mts +22 -0
  14. package/dist/src/graph/load.utils.mjs +59 -0
  15. package/dist/src/protocol/service.d.mts +101 -0
  16. package/dist/src/protocol/service.mjs +568 -0
  17. package/dist/src/protocol/session/event-normalizers.d.mts +52 -0
  18. package/dist/src/protocol/session/index.d.mts +261 -0
  19. package/dist/src/protocol/session/index.mjs +826 -0
  20. package/dist/src/protocol/session/namespace.d.mts +47 -0
  21. package/dist/src/protocol/session/namespace.mjs +62 -0
  22. package/dist/src/protocol/types.d.mts +121 -0
  23. package/dist/src/protocol/types.mjs +1 -0
  24. package/dist/src/schemas.d.mts +1552 -0
  25. package/dist/src/semver/index.d.mts +15 -0
  26. package/dist/src/semver/index.mjs +46 -0
  27. package/dist/src/semver/satisfiesPeerRange.d.mts +1 -0
  28. package/dist/src/semver/satisfiesPeerRange.mjs +19 -0
  29. package/dist/src/state.d.mts +3 -0
  30. package/dist/src/state.mjs +30 -0
  31. package/dist/src/storage/context.d.mts +3 -0
  32. package/dist/src/storage/context.mjs +11 -0
  33. package/dist/src/storage/ops.mjs +1281 -0
  34. package/dist/src/stream.d.mts +64 -0
  35. package/dist/src/stream.mjs +427 -0
  36. package/dist/src/utils/hono.d.mts +5 -0
  37. package/dist/src/utils/hono.mjs +24 -0
  38. package/dist/src/utils/runnableConfig.d.mts +3 -0
  39. package/dist/src/utils/runnableConfig.mjs +45 -0
  40. package/dist/src/webhook.d.mts +11 -0
  41. package/dist/src/webhook.mjs +30 -0
  42. package/package.json +4 -4
@@ -0,0 +1,101 @@
1
+ import type { RunsRepo, ThreadsRepo } from "../storage/types.mjs";
2
+ import type { EventSinkEntry, EventSinkFilter, ProtocolCommand, ProtocolError, ProtocolEvent, ProtocolSuccess, ThreadRecord, ProtocolTransportName } from "./types.mjs";
3
+ type ServiceBindings = {
4
+ runs: RunsRepo;
5
+ threads: ThreadsRepo;
6
+ };
7
+ /**
8
+ * Transport-agnostic sink used to forward normalized protocol events into a
9
+ * concrete delivery mechanism such as WebSocket or SSE.
10
+ */
11
+ type EventSink = (message: ProtocolEvent) => Promise<void> | void;
12
+ /**
13
+ * Thread-scoped connection registry and command dispatcher.
14
+ *
15
+ * In the thread-centric protocol, a `ThreadRecord` holds ephemeral
16
+ * connection state for an active client interacting with a thread. The
17
+ * thread itself is durable (lives in the checkpoint store); records are
18
+ * created lazily on first interaction and dropped when all connections
19
+ * close.
20
+ */
21
+ export declare class ProtocolService {
22
+ private readonly bindings;
23
+ private readonly threads;
24
+ constructor(bindings: ServiceBindings);
25
+ getThread(threadId: string): ThreadRecord | undefined;
26
+ /**
27
+ * Get or create the in-memory record for a thread. Records hold
28
+ * ephemeral connection state (event sinks, current run session) and
29
+ * are created on first use for any thread the client targets.
30
+ */
31
+ ensureThread(options: {
32
+ threadId: string;
33
+ transport: ProtocolTransportName;
34
+ auth?: ThreadRecord["auth"];
35
+ sendEvent?: EventSink;
36
+ }): ThreadRecord;
37
+ /**
38
+ * Attach a live transport consumer (WebSocket) and flush any buffered
39
+ * events.
40
+ */
41
+ attachEventSink(threadId: string, sendEvent: EventSink): Promise<ThreadRecord>;
42
+ /**
43
+ * Attach a filtered SSE event sink and replay buffered events that
44
+ * match the filter.
45
+ *
46
+ * The sink is flagged `pendingReplay` while draining so that the live
47
+ * `send` path skips it — preventing live events from interleaving with
48
+ * the replay loop's awaits and producing out-of-order delivery.
49
+ */
50
+ attachFilteredEventSink(threadId: string, sink: EventSinkEntry): Promise<ThreadRecord>;
51
+ /**
52
+ * Remove an SSE event sink when the connection closes.
53
+ */
54
+ detachEventSink(threadId: string, sinkId: string): void;
55
+ closeThread(threadId: string): Promise<void>;
56
+ /**
57
+ * Route a protocol command on a thread.
58
+ *
59
+ * `subscription.subscribe` can resolve to `null` on ordered
60
+ * transports (WebSocket) — see
61
+ * `ProtocolSession.handleSubscribeForResponse` for the rationale.
62
+ * Callers must treat `null` as "response already sent on the wire"
63
+ * and skip any additional send.
64
+ */
65
+ handleCommand(threadId: string, command: ProtocolCommand): Promise<ProtocolSuccess | ProtocolError | null>;
66
+ /**
67
+ * Start a new run, resume an interrupted run, or continue on the
68
+ * thread depending on its current state.
69
+ */
70
+ private handleRunStart;
71
+ private handleInputRespond;
72
+ private createOrResumeRun;
73
+ private hasPendingInterrupts;
74
+ private hasPendingInterruptsForThread;
75
+ private handleStateGet;
76
+ private forwardToRunSession;
77
+ /**
78
+ * Drain any WebSocket subscribes that arrived before the first run
79
+ * session was bound. Called from `createOrResumeRun` right after
80
+ * {@link ensureRunSession} sets up `record.session`.
81
+ *
82
+ * Each parked command is forwarded to the freshly-bound session,
83
+ * added to `activeSubscriptions` so it persists across subsequent
84
+ * runs, and its deferred `handleCommand` promise is resolved so the
85
+ * WebSocket handler can finally send the response.
86
+ */
87
+ private drainPendingSubscribes;
88
+ /**
89
+ * Bind the thread record to a concrete LangGraph run and forward
90
+ * normalized protocol events to attached sinks.
91
+ */
92
+ private ensureRunSession;
93
+ private requireThread;
94
+ error(id: number | null, code: ProtocolError["error"], message: string): ProtocolError;
95
+ }
96
+ /**
97
+ * Check whether a protocol event matches an SSE event sink filter.
98
+ * Mirrors the subscription matching logic in {@link RunProtocolSession}.
99
+ */
100
+ export declare function matchesSinkFilter(filter: EventSinkFilter, event: ProtocolEvent): boolean;
101
+ export {};
@@ -0,0 +1,568 @@
1
+ import { v7 as uuid7 } from "uuid";
2
+ import { getAssistantId } from "../graph/load.mjs";
3
+ import { isSupportedChannel, isRecord as isRecordInternal, } from "./session/internal-types.mjs";
4
+ import { PROTOCOL_STREAM_RUN_KEY } from "./constants.mjs";
5
+ import { RunProtocolSession } from "./session/index.mjs";
6
+ const DEFAULT_RUN_STREAM_MODES = [
7
+ "values",
8
+ "updates",
9
+ "messages",
10
+ "tools",
11
+ "custom",
12
+ "tasks",
13
+ ];
14
+ const isRecord = (value) => typeof value === "object" && value !== null;
15
+ const normalizeForkFrom = (value) => {
16
+ if (!isRecord(value))
17
+ return undefined;
18
+ const checkpointId = value.checkpointId;
19
+ if (typeof checkpointId !== "string" || checkpointId.length === 0) {
20
+ return undefined;
21
+ }
22
+ return { checkpointId };
23
+ };
24
+ const normalizeRunStart = (value) => {
25
+ if (isRecord(value)) {
26
+ return {
27
+ assistant_id: typeof value.assistant_id === "string" ? value.assistant_id : "",
28
+ input: value.input,
29
+ config: isRecord(value.config) ? value.config : undefined,
30
+ metadata: isRecord(value.metadata) ? value.metadata : undefined,
31
+ forkFrom: normalizeForkFrom(value.forkFrom),
32
+ };
33
+ }
34
+ return {
35
+ assistant_id: "",
36
+ input: undefined,
37
+ config: undefined,
38
+ metadata: undefined,
39
+ };
40
+ };
41
+ const normalizeKeys = (value) => {
42
+ if (!Array.isArray(value))
43
+ return undefined;
44
+ if (!value.every((entry) => typeof entry === "string"))
45
+ return undefined;
46
+ return value;
47
+ };
48
+ /**
49
+ * Thread-scoped connection registry and command dispatcher.
50
+ *
51
+ * In the thread-centric protocol, a `ThreadRecord` holds ephemeral
52
+ * connection state for an active client interacting with a thread. The
53
+ * thread itself is durable (lives in the checkpoint store); records are
54
+ * created lazily on first interaction and dropped when all connections
55
+ * close.
56
+ */
57
+ export class ProtocolService {
58
+ bindings;
59
+ threads = new Map();
60
+ constructor(bindings) {
61
+ this.bindings = bindings;
62
+ }
63
+ getThread(threadId) {
64
+ return this.threads.get(threadId);
65
+ }
66
+ /**
67
+ * Get or create the in-memory record for a thread. Records hold
68
+ * ephemeral connection state (event sinks, current run session) and
69
+ * are created on first use for any thread the client targets.
70
+ */
71
+ ensureThread(options) {
72
+ let record = this.threads.get(options.threadId);
73
+ if (record == null) {
74
+ record = {
75
+ threadId: options.threadId,
76
+ transport: options.transport,
77
+ auth: options.auth,
78
+ seq: 0,
79
+ session: undefined,
80
+ currentRunId: undefined,
81
+ sendEvent: options.sendEvent,
82
+ eventSinks: new Map(),
83
+ queuedEvents: [],
84
+ activeSubscriptions: [],
85
+ pendingSubscribes: [],
86
+ };
87
+ this.threads.set(options.threadId, record);
88
+ }
89
+ else if (options.sendEvent != null) {
90
+ record.sendEvent = options.sendEvent;
91
+ }
92
+ return record;
93
+ }
94
+ /**
95
+ * Attach a live transport consumer (WebSocket) and flush any buffered
96
+ * events.
97
+ */
98
+ async attachEventSink(threadId, sendEvent) {
99
+ const record = this.requireThread(threadId);
100
+ record.sendEvent = sendEvent;
101
+ for (const event of record.queuedEvents.splice(0)) {
102
+ await sendEvent(event);
103
+ }
104
+ return record;
105
+ }
106
+ /**
107
+ * Attach a filtered SSE event sink and replay buffered events that
108
+ * match the filter.
109
+ *
110
+ * The sink is flagged `pendingReplay` while draining so that the live
111
+ * `send` path skips it — preventing live events from interleaving with
112
+ * the replay loop's awaits and producing out-of-order delivery.
113
+ */
114
+ async attachFilteredEventSink(threadId, sink) {
115
+ const record = this.requireThread(threadId);
116
+ sink.pendingReplay = true;
117
+ record.eventSinks.set(sink.id, sink);
118
+ try {
119
+ // Walk the buffer by index so events pushed during an `await`
120
+ // are still picked up in order before we unblock live delivery.
121
+ let cursor = 0;
122
+ while (cursor < record.queuedEvents.length) {
123
+ const event = record.queuedEvents[cursor++];
124
+ if (matchesSinkFilter(sink.filter, event)) {
125
+ await sink.send(event);
126
+ }
127
+ }
128
+ }
129
+ finally {
130
+ sink.pendingReplay = false;
131
+ }
132
+ return record;
133
+ }
134
+ /**
135
+ * Remove an SSE event sink when the connection closes.
136
+ */
137
+ detachEventSink(threadId, sinkId) {
138
+ const record = this.threads.get(threadId);
139
+ record?.eventSinks.delete(sinkId);
140
+ }
141
+ async closeThread(threadId) {
142
+ const record = this.threads.get(threadId);
143
+ if (record == null)
144
+ return;
145
+ // Resolve any still-parked WebSocket subscribes with `no_such_run` so
146
+ // the transport's `onMessage` await unblocks and doesn't leak. The
147
+ // thread is going away before a session was ever bound, so the client
148
+ // will never receive a valid subscription_id anyway.
149
+ const pending = record.pendingSubscribes.splice(0);
150
+ for (const { command, resolve } of pending) {
151
+ resolve({
152
+ type: "error",
153
+ id: command.id,
154
+ error: "no_such_run",
155
+ message: "Thread closed before a run was bound.",
156
+ });
157
+ }
158
+ await record.session?.close();
159
+ this.threads.delete(threadId);
160
+ }
161
+ /**
162
+ * Route a protocol command on a thread.
163
+ *
164
+ * `subscription.subscribe` can resolve to `null` on ordered
165
+ * transports (WebSocket) — see
166
+ * `ProtocolSession.handleSubscribeForResponse` for the rationale.
167
+ * Callers must treat `null` as "response already sent on the wire"
168
+ * and skip any additional send.
169
+ */
170
+ async handleCommand(threadId, command) {
171
+ const record = this.requireThread(threadId);
172
+ switch (command.method) {
173
+ case "run.start":
174
+ return await this.handleRunStart(record, command);
175
+ case "input.respond":
176
+ return await this.handleInputRespond(record, command);
177
+ case "state.get":
178
+ return await this.handleStateGet(record, command);
179
+ default:
180
+ return await this.forwardToRunSession(record, command);
181
+ }
182
+ }
183
+ /**
184
+ * Start a new run, resume an interrupted run, or continue on the
185
+ * thread depending on its current state.
186
+ */
187
+ async handleRunStart(record, command) {
188
+ const params = normalizeRunStart(command.params);
189
+ if (!params.assistant_id) {
190
+ return this.error(command.id, "invalid_argument", "run.start requires an assistant_id.");
191
+ }
192
+ if (record.assistantId != null &&
193
+ record.assistantId !== params.assistant_id) {
194
+ return this.error(command.id, "invalid_argument", `Thread ${record.threadId} is bound to assistant ${record.assistantId}; cannot run ${params.assistant_id}.`);
195
+ }
196
+ record.assistantId = params.assistant_id;
197
+ const run = await this.createOrResumeRun(record, params);
198
+ return {
199
+ type: "success",
200
+ id: command.id,
201
+ result: { run_id: run.run_id },
202
+ meta: {
203
+ thread_id: record.threadId,
204
+ applied_through_seq: record.seq,
205
+ },
206
+ };
207
+ }
208
+ async handleInputRespond(record, command) {
209
+ const params = isRecord(command.params)
210
+ ? command.params
211
+ : {};
212
+ if (typeof params.interrupt_id !== "string") {
213
+ return this.error(command.id, "invalid_argument", "input.respond requires an interrupt_id.");
214
+ }
215
+ if (record.assistantId == null) {
216
+ return this.error(command.id, "no_such_run", "Thread has no active assistant; call run.start first.");
217
+ }
218
+ const currentRun = record.currentRunId != null
219
+ ? await this.bindings.runs.get(record.currentRunId, record.threadId, record.auth)
220
+ : null;
221
+ const hasPendingInterrupts = await this.hasPendingInterrupts(record);
222
+ if (currentRun == null && !hasPendingInterrupts) {
223
+ return this.error(command.id, "no_such_run", "No interrupted run is bound to this thread.");
224
+ }
225
+ if (!hasPendingInterrupts) {
226
+ return this.error(command.id, "invalid_argument", "input.respond can only be used while the run is interrupted.");
227
+ }
228
+ await this.createOrResumeRun(record, {
229
+ assistant_id: record.assistantId,
230
+ input: { [params.interrupt_id]: params.response },
231
+ config: undefined,
232
+ metadata: undefined,
233
+ });
234
+ return {
235
+ type: "success",
236
+ id: command.id,
237
+ result: {},
238
+ meta: {
239
+ thread_id: record.threadId,
240
+ applied_through_seq: record.seq,
241
+ },
242
+ };
243
+ }
244
+ async createOrResumeRun(record, params) {
245
+ const assistantId = getAssistantId(params.assistant_id);
246
+ const currentRun = record.currentRunId != null
247
+ ? await this.bindings.runs.get(record.currentRunId, record.threadId, record.auth)
248
+ : null;
249
+ const currentStatus = currentRun?.status;
250
+ const hasPendingInterrupts = params.input != null
251
+ ? await this.hasPendingInterruptsForThread(record.threadId, record.auth)
252
+ : false;
253
+ const isResume = params.input != null &&
254
+ ((currentRun != null && currentStatus === "interrupted") ||
255
+ hasPendingInterrupts);
256
+ /**
257
+ * When `forkFrom: { checkpointId }` is present, promote it to
258
+ * `configurable.checkpoint_id` so the engine replays from the
259
+ * requested fork target. `forkFrom` is merged last so it wins over
260
+ * any `checkpoint_id` the caller may have pre-baked into
261
+ * `config.configurable`; in resume flows we intentionally skip the
262
+ * promotion because a resume must follow the thread's active
263
+ * checkpoint, not a historical fork.
264
+ */
265
+ const runConfig = {
266
+ ...params.config,
267
+ configurable: {
268
+ ...params.config?.configurable,
269
+ thread_id: record.threadId,
270
+ ...(!isResume && params.forkFrom?.checkpointId != null
271
+ ? { checkpoint_id: params.forkFrom.checkpointId }
272
+ : {}),
273
+ },
274
+ };
275
+ const runPayload = {
276
+ assistant_id: assistantId,
277
+ input: isResume ? null : params.input,
278
+ command: isResume
279
+ ? { resume: params.input }
280
+ : undefined,
281
+ config: runConfig,
282
+ metadata: params.metadata,
283
+ stream_mode: DEFAULT_RUN_STREAM_MODES,
284
+ stream_subgraphs: true,
285
+ stream_resumable: true,
286
+ if_not_exists: "create",
287
+ multitask_strategy: "interrupt",
288
+ };
289
+ const [run] = await this.bindings.runs.put(uuid7(), assistantId, {
290
+ input: runPayload.input,
291
+ command: runPayload.command,
292
+ config: runPayload.config,
293
+ context: undefined,
294
+ stream_mode: runPayload.stream_mode,
295
+ interrupt_before: undefined,
296
+ interrupt_after: undefined,
297
+ temporary: false,
298
+ subgraphs: runPayload.stream_subgraphs,
299
+ resumable: runPayload.stream_resumable,
300
+ [PROTOCOL_STREAM_RUN_KEY]: true,
301
+ }, {
302
+ threadId: record.threadId,
303
+ metadata: runPayload.metadata,
304
+ status: "pending",
305
+ multitaskStrategy: runPayload.multitask_strategy,
306
+ preventInsertInInflight: false,
307
+ ifNotExists: runPayload.if_not_exists,
308
+ }, record.auth);
309
+ await this.ensureRunSession(record, run);
310
+ record.currentRunId = run.run_id;
311
+ // For WebSocket transports, register subscriptions on the session
312
+ // BEFORE starting it so the success responses for parked and sticky
313
+ // subscribes reach the client ahead of the session's first events.
314
+ // `ensureRunSession` intentionally leaves the session unstarted; we
315
+ // kick `session.start()` off after the drain below.
316
+ //
317
+ // 1. Replay sticky subscriptions from previous runs (empty on the
318
+ // first run). These must land first so that parked subs from
319
+ // the current run are appended at the end of
320
+ // `activeSubscriptions` and aren't double-applied here.
321
+ // 2. Drain subscribes that arrived while `record.session` was
322
+ // still null. Their deferred `handleCommand` responses resolve
323
+ // here — the awaiting WebSocket `onMessage` handlers will flush
324
+ // `ws.send(success)` on the next microtask tick, which runs
325
+ // before `session.start()`'s first `pushEvent` completes its
326
+ // `await` chain. The client therefore has every subscription
327
+ // handle registered in `#subscriptions` by the time the run's
328
+ // initial lifecycle/values events arrive.
329
+ if (record.transport === "websocket") {
330
+ for (const cmd of record.activeSubscriptions) {
331
+ await record.session?.handleProtocolCommand(cmd, {
332
+ thread_id: record.threadId,
333
+ applied_through_seq: record.seq,
334
+ }, { deliverResponseInline: true });
335
+ }
336
+ await this.drainPendingSubscribes(record);
337
+ }
338
+ await record.session?.start();
339
+ return run;
340
+ }
341
+ async hasPendingInterrupts(record) {
342
+ return this.hasPendingInterruptsForThread(record.threadId, record.auth);
343
+ }
344
+ async hasPendingInterruptsForThread(threadId, auth) {
345
+ try {
346
+ const state = await this.bindings.threads.state.get({ configurable: { thread_id: threadId } }, { subgraphs: true }, auth);
347
+ return (state.tasks ?? []).some((task) => Array.isArray(task.interrupts) && task.interrupts.length > 0);
348
+ }
349
+ catch {
350
+ return false;
351
+ }
352
+ }
353
+ async handleStateGet(record, command) {
354
+ const params = isRecord(command.params)
355
+ ? command.params
356
+ : {};
357
+ const values = await this.bindings.threads.state.get({ configurable: { thread_id: record.threadId } }, { subgraphs: true }, record.auth);
358
+ const checkpointConfig = isRecord(values.config?.configurable)
359
+ ? values.config.configurable
360
+ : undefined;
361
+ const checkpoint = checkpointConfig != null &&
362
+ typeof checkpointConfig.checkpoint_id === "string"
363
+ ? {
364
+ id: checkpointConfig.checkpoint_id,
365
+ ...(typeof checkpointConfig.checkpoint_ns === "string"
366
+ ? { ns: checkpointConfig.checkpoint_ns }
367
+ : {}),
368
+ }
369
+ : undefined;
370
+ const requestedKeys = normalizeKeys(params.keys);
371
+ const filteredValues = requestedKeys == null
372
+ ? values.values
373
+ : Object.fromEntries(Object.entries(values.values).filter(([key]) => requestedKeys.includes(key)));
374
+ return {
375
+ type: "success",
376
+ id: command.id,
377
+ result: {
378
+ values: filteredValues,
379
+ checkpoint,
380
+ },
381
+ meta: {
382
+ thread_id: record.threadId,
383
+ applied_through_seq: record.seq,
384
+ },
385
+ };
386
+ }
387
+ async forwardToRunSession(record, command) {
388
+ const runSession = record.session;
389
+ if (runSession == null) {
390
+ // WebSocket subscribes can arrive before the concurrent `run.start`
391
+ // has bound a session (the SDK's root pump + legacy lifecycle/values
392
+ // subs are opened eagerly so no events are missed on fast runs).
393
+ // Park the response promise here and resolve it once the first
394
+ // session is bound — see `drainPendingSubscribes`. Mirrors the
395
+ // existing cross-run `activeSubscriptions` replay path.
396
+ if (command.method === "subscription.subscribe" &&
397
+ record.transport === "websocket") {
398
+ return await new Promise((resolve) => {
399
+ record.pendingSubscribes.push({ command, resolve });
400
+ });
401
+ }
402
+ return this.error(command.id, "no_such_run", "No active run is bound to this thread.");
403
+ }
404
+ if (command.method === "subscription.subscribe" &&
405
+ record.transport === "websocket") {
406
+ record.activeSubscriptions.push(command);
407
+ }
408
+ return await runSession.handleProtocolCommand(command, {
409
+ thread_id: record.threadId,
410
+ applied_through_seq: record.seq,
411
+ },
412
+ // WebSocket is an ordered single-channel transport: events and
413
+ // the subscribe response share one wire. Emit the response
414
+ // first via the session's send queue so the client registers
415
+ // the subscription handle before the replay events arrive
416
+ // (otherwise the per-sub fan-out drops them). HTTP `/commands`
417
+ // keeps the default return-the-response behaviour so the
418
+ // response lands in the HTTP body.
419
+ record.transport === "websocket"
420
+ ? { deliverResponseInline: true }
421
+ : undefined);
422
+ }
423
+ /**
424
+ * Drain any WebSocket subscribes that arrived before the first run
425
+ * session was bound. Called from `createOrResumeRun` right after
426
+ * {@link ensureRunSession} sets up `record.session`.
427
+ *
428
+ * Each parked command is forwarded to the freshly-bound session,
429
+ * added to `activeSubscriptions` so it persists across subsequent
430
+ * runs, and its deferred `handleCommand` promise is resolved so the
431
+ * WebSocket handler can finally send the response.
432
+ */
433
+ async drainPendingSubscribes(record) {
434
+ if (record.session == null)
435
+ return;
436
+ if (record.pendingSubscribes.length === 0)
437
+ return;
438
+ const pending = record.pendingSubscribes.splice(0);
439
+ for (const { command, resolve } of pending) {
440
+ record.activeSubscriptions.push(command);
441
+ const response = await record.session.handleProtocolCommand(command, {
442
+ thread_id: record.threadId,
443
+ applied_through_seq: record.seq,
444
+ },
445
+ // Parked subscribes are always WebSocket-origin (see
446
+ // `forwardToRunSession`), so the response must land on the
447
+ // wire before the soon-to-start session's first events.
448
+ { deliverResponseInline: true });
449
+ resolve(response);
450
+ }
451
+ }
452
+ /**
453
+ * Bind the thread record to a concrete LangGraph run and forward
454
+ * normalized protocol events to attached sinks.
455
+ */
456
+ async ensureRunSession(record, run) {
457
+ if (record.session != null && record.currentRunId === run.run_id)
458
+ return;
459
+ await record.session?.close();
460
+ const source = this.bindings.runs.stream.join(run.run_id, run.thread_id, {
461
+ signal: undefined,
462
+ cancelOnDisconnect: false,
463
+ lastEventId: run.kwargs.resumable ? "-1" : undefined,
464
+ }, record.auth);
465
+ const isSSE = record.transport === "sse-http";
466
+ const session = new RunProtocolSession({
467
+ runId: run.run_id,
468
+ threadId: run.thread_id,
469
+ auth: record.auth,
470
+ initialRun: run,
471
+ getRun: () => this.bindings.runs.get(run.run_id, run.thread_id, record.auth),
472
+ getThreadState: async () => await this.bindings.threads.state.get({ configurable: { thread_id: run.thread_id } }, { subgraphs: true }, record.auth),
473
+ source,
474
+ startSeq: record.seq,
475
+ passthrough: isSSE,
476
+ send: async (payload) => {
477
+ const parsed = JSON.parse(payload);
478
+ record.seq = Math.max(record.seq, parsed.seq ?? record.seq);
479
+ if (isSSE) {
480
+ // Always buffer events so late-attaching sinks can replay
481
+ // matching history via attachFilteredEventSink(). Sinks with
482
+ // `pendingReplay` are skipped here — their replay loop will
483
+ // deliver this event in buffer order.
484
+ record.queuedEvents.push(parsed);
485
+ for (const sink of record.eventSinks.values()) {
486
+ if (sink.pendingReplay)
487
+ continue;
488
+ if (matchesSinkFilter(sink.filter, parsed)) {
489
+ await sink.send(parsed);
490
+ }
491
+ }
492
+ }
493
+ else if (record.sendEvent != null) {
494
+ await record.sendEvent(parsed);
495
+ }
496
+ else {
497
+ record.queuedEvents.push(parsed);
498
+ }
499
+ },
500
+ });
501
+ record.session = session;
502
+ // NOTE: `session.start()` is intentionally deferred to the caller
503
+ // (see {@link createOrResumeRun}). On WebSocket we need to drain any
504
+ // parked `subscription.subscribe` commands onto the fresh session —
505
+ // and let the client observe their success responses — BEFORE the
506
+ // session begins emitting events. Otherwise the initial run events
507
+ // are serialised ahead of the subscribe responses on the wire and
508
+ // the client drops them because it hasn't registered the matching
509
+ // subscription handles in `#subscriptions` yet.
510
+ }
511
+ requireThread(threadId) {
512
+ const record = this.threads.get(threadId);
513
+ if (record == null) {
514
+ throw new Error(`No thread record found for ${threadId}`);
515
+ }
516
+ return record;
517
+ }
518
+ error(id, code, message) {
519
+ return {
520
+ type: "error",
521
+ id,
522
+ error: code,
523
+ message,
524
+ };
525
+ }
526
+ }
527
+ function isPrefixMatch(namespace, prefix) {
528
+ if (prefix.length > namespace.length)
529
+ return false;
530
+ return prefix.every((segment, i) => namespace[i] === segment);
531
+ }
532
+ /**
533
+ * Check whether a protocol event matches an SSE event sink filter.
534
+ * Mirrors the subscription matching logic in {@link RunProtocolSession}.
535
+ */
536
+ export function matchesSinkFilter(filter, event) {
537
+ if (filter.since != null && (event.seq ?? 0) <= filter.since)
538
+ return false;
539
+ const channel = event.method === "input.requested"
540
+ ? "input"
541
+ : isSupportedChannel(event.method)
542
+ ? event.method
543
+ : undefined;
544
+ if (channel == null)
545
+ return false;
546
+ let channelMatched = filter.channels.has(channel);
547
+ if (!channelMatched && channel === "custom") {
548
+ const params = event.params;
549
+ const eventName = isRecordInternal(params.data) && typeof params.data.name === "string"
550
+ ? params.data.name
551
+ : undefined;
552
+ if (eventName != null) {
553
+ channelMatched = filter.channels.has(`custom:${eventName}`);
554
+ }
555
+ }
556
+ if (!channelMatched)
557
+ return false;
558
+ if (filter.namespaces == null || filter.namespaces.length === 0) {
559
+ return true;
560
+ }
561
+ return filter.namespaces.some((prefix) => {
562
+ if (!isPrefixMatch(event.params.namespace, prefix))
563
+ return false;
564
+ if (filter.depth == null)
565
+ return true;
566
+ return event.params.namespace.length - prefix.length <= filter.depth;
567
+ });
568
+ }