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

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.
@@ -7,7 +7,7 @@ import { serialiseAsDict } from "../../utils/serde.mjs";
7
7
  import { jsonExtra } from "../../utils/hono.mjs";
8
8
  import { RunProtocolSession } from "../../protocol/session/index.mjs";
9
9
  import { PROTOCOL_STREAM_RUN_KEY } from "../../protocol/constants.mjs";
10
- import { matchesSinkFilter } from "../../protocol/service.mjs";
10
+ import { matchesSinkFilter, normalizeMultitaskStrategy, DEFAULT_MULTITASK_STRATEGY, } from "../../protocol/service.mjs";
11
11
  import { ProtocolCommandSchema, ThreadIdSchema, isRecord, createStubRun, } from "./utils.mjs";
12
12
  import { DEFAULT_PROTOCOL_STREAM_MODES } from "./constants.mjs";
13
13
  const EventsFilterSchema = z
@@ -112,6 +112,19 @@ export function registerProtocolRoutes(api, context, upgradeWebSocket) {
112
112
  thread.currentRun = run;
113
113
  return protocolSession;
114
114
  }
115
+ async function hasPendingInterruptsForThread(thread) {
116
+ const assistantId = thread.assistantId;
117
+ if (assistantId == null)
118
+ return false;
119
+ try {
120
+ const graph = await context.getGraph(assistantId);
121
+ const snapshot = await graph.getState({ configurable: { thread_id: thread.threadId } }, { subgraphs: true });
122
+ return (snapshot.tasks ?? []).some((task) => Array.isArray(task.interrupts) && task.interrupts.length > 0);
123
+ }
124
+ catch {
125
+ return false;
126
+ }
127
+ }
115
128
  async function handleRunStart(thread, command) {
116
129
  const params = isRecord(command.params)
117
130
  ? command.params
@@ -165,21 +178,39 @@ export function registerProtocolRoutes(api, context, upgradeWebSocket) {
165
178
  },
166
179
  });
167
180
  }
168
- // Promote SDK-side `forkFrom: { checkpointId }` into
169
- // `configurable.checkpoint_id` so the engine replays from the
170
- // requested fork target. This mirrors the promotion performed by
171
- // `ProtocolService.createOrResumeRun` in the non-embed path and
172
- // closes the "client sends forkFrom, server drops it" gap.
181
+ // Fork/time-travel replays from `config.configurable.checkpoint_id`
182
+ // the single legacy-compliant fork field. The SDK folds its
183
+ // `forkFrom` convenience option into this field client-side, so the
184
+ // server reads it from there rather than a top-level `forkFrom`.
185
+ // `createStubRun` only reliably forwards a fork target via the
186
+ // top-level `checkpoint_id`, so lift it out of `configurable` below.
173
187
  const forkCheckpointId = (() => {
174
- if (!isRecord(params.forkFrom))
175
- return undefined;
176
- const id = params.forkFrom.checkpointId;
177
- return typeof id === "string" && id.length > 0 ? id : undefined;
188
+ const configurable = isRecord(params.config) && isRecord(params.config.configurable)
189
+ ? params.config.configurable
190
+ : undefined;
191
+ const checkpointId = configurable?.checkpoint_id;
192
+ return typeof checkpointId === "string" && checkpointId.length > 0
193
+ ? checkpointId
194
+ : undefined;
178
195
  })();
196
+ const currentRun = thread.currentRun;
197
+ const currentStatus = currentRun?.status;
198
+ const hasPendingInterrupts = params.input != null
199
+ ? await hasPendingInterruptsForThread(thread)
200
+ : false;
201
+ const isResume = params.input != null &&
202
+ ((currentRun != null && currentStatus === "interrupted") ||
203
+ hasPendingInterrupts);
204
+ if (isResume) {
205
+ // Drop stale lifecycle events from the paused run so late-attaching
206
+ // sinks do not replay `lifecycle.interrupted` after resume.
207
+ thread.queuedEvents.length = 0;
208
+ }
179
209
  const run = createStubRun(thread.threadId, {
180
210
  assistant_id: assistantId,
181
211
  on_disconnect: "cancel",
182
- input: params.input ?? null,
212
+ input: isResume ? null : (params.input ?? null),
213
+ command: isResume ? { resume: params.input } : undefined,
183
214
  config: {
184
215
  configurable: {
185
216
  ...(isRecord(params.config) && isRecord(params.config.configurable)
@@ -192,7 +223,13 @@ export function registerProtocolRoutes(api, context, upgradeWebSocket) {
192
223
  // fact, *replaces* any inline configurable the caller passed),
193
224
  // so this is the only reliable way to reach the engine with a
194
225
  // fork target.
195
- ...(forkCheckpointId != null ? { checkpoint_id: forkCheckpointId } : {}),
226
+ ...(forkCheckpointId != null && !isResume
227
+ ? { checkpoint_id: forkCheckpointId }
228
+ : {}),
229
+ // Honor the caller's `multitaskStrategy`, falling back to `enqueue`
230
+ // to match the non-embed protocol-v2 server and the Python default.
231
+ multitask_strategy: normalizeMultitaskStrategy(params.multitaskStrategy) ??
232
+ DEFAULT_MULTITASK_STRATEGY,
196
233
  metadata: Object.keys(runMetadata).length > 0 ? runMetadata : undefined,
197
234
  stream_mode: DEFAULT_PROTOCOL_STREAM_MODES,
198
235
  stream_subgraphs: true,
@@ -214,8 +251,37 @@ export function registerProtocolRoutes(api, context, upgradeWebSocket) {
214
251
  const params = isRecord(command.params)
215
252
  ? command.params
216
253
  : {};
217
- const interruptId = params.interrupt_id;
218
- if (typeof interruptId !== "string") {
254
+ // Build the resume input map. The SDK sends either a single
255
+ // `interrupt_id` / `response` or a `responses` batch (several
256
+ // interrupts at the same checkpoint, resumed in one command) — the
257
+ // protocol's `InputRespondOne` / `InputRespondMany` variants. Read
258
+ // leniently to tolerate clients pinned to older bindings.
259
+ const resumeInput = {};
260
+ if (Array.isArray(params.responses)) {
261
+ for (const entry of params.responses) {
262
+ if (!isRecord(entry) || typeof entry.interrupt_id !== "string") {
263
+ return jsonResponse({
264
+ type: "error",
265
+ id: command.id,
266
+ error: "invalid_argument",
267
+ message: "input.respond responses entries require an interrupt_id.",
268
+ });
269
+ }
270
+ resumeInput[entry.interrupt_id] = entry.response;
271
+ }
272
+ if (Object.keys(resumeInput).length === 0) {
273
+ return jsonResponse({
274
+ type: "error",
275
+ id: command.id,
276
+ error: "invalid_argument",
277
+ message: "input.respond requires at least one response.",
278
+ });
279
+ }
280
+ }
281
+ else if (typeof params.interrupt_id === "string") {
282
+ resumeInput[params.interrupt_id] = params.response;
283
+ }
284
+ else {
219
285
  return jsonResponse({
220
286
  type: "error",
221
287
  id: command.id,
@@ -236,7 +302,12 @@ export function registerProtocolRoutes(api, context, upgradeWebSocket) {
236
302
  assistant_id: assistantId,
237
303
  on_disconnect: "cancel",
238
304
  input: null,
239
- command: { resume: { [interruptId]: params.response } },
305
+ command: { resume: resumeInput },
306
+ // Carry the SDK's `respond({ config, metadata })` onto the resumed
307
+ // run so it applies the same run config / metadata a fresh
308
+ // `run.start` would (both are part of the `input.respond` params).
309
+ config: isRecord(params.config) ? params.config : undefined,
310
+ metadata: isRecord(params.metadata) ? params.metadata : undefined,
240
311
  stream_mode: DEFAULT_PROTOCOL_STREAM_MODES,
241
312
  stream_subgraphs: true,
242
313
  });
@@ -1,4 +1,4 @@
1
- import type { RunsRepo, ThreadsRepo } from "../storage/types.mjs";
1
+ import type { RunsRepo, ThreadsRepo, MultitaskStrategy } from "../storage/types.mjs";
2
2
  import type { EventSinkEntry, EventSinkFilter, ProtocolCommand, ProtocolError, ProtocolEvent, ProtocolSuccess, ThreadRecord, ProtocolTransportName } from "./types.mjs";
3
3
  type ServiceBindings = {
4
4
  runs: RunsRepo;
@@ -9,6 +9,14 @@ type ServiceBindings = {
9
9
  * concrete delivery mechanism such as WebSocket or SSE.
10
10
  */
11
11
  type EventSink = (message: ProtocolEvent) => Promise<void> | void;
12
+ export declare const DEFAULT_MULTITASK_STRATEGY: MultitaskStrategy;
13
+ /**
14
+ * Resolve the per-run `multitaskStrategy`. Honor it when it is one of the
15
+ * recognized strategies, otherwise return `undefined` so the caller falls
16
+ * back to {@link DEFAULT_MULTITASK_STRATEGY}. Lenient by design (matches
17
+ * the rest of `normalizeRunStart` and the Python reference server).
18
+ */
19
+ export declare const normalizeMultitaskStrategy: (value: unknown) => MultitaskStrategy | undefined;
12
20
  /**
13
21
  * Thread-scoped connection registry and command dispatcher.
14
22
  *
@@ -12,14 +12,28 @@ const DEFAULT_RUN_STREAM_MODES = [
12
12
  "tasks",
13
13
  ];
14
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 };
15
+ // Concurrency strategies accepted on `run.start`, matching the four values
16
+ // the SDK's `multitaskStrategy` option can take.
17
+ const VALID_MULTITASK_STRATEGIES = [
18
+ "reject",
19
+ "rollback",
20
+ "interrupt",
21
+ "enqueue",
22
+ ];
23
+ // Legacy stream-endpoint default: queue new runs behind active ones instead
24
+ // of interrupting. Used when the caller omits `multitaskStrategy`.
25
+ export const DEFAULT_MULTITASK_STRATEGY = "enqueue";
26
+ /**
27
+ * Resolve the per-run `multitaskStrategy`. Honor it when it is one of the
28
+ * recognized strategies, otherwise return `undefined` so the caller falls
29
+ * back to {@link DEFAULT_MULTITASK_STRATEGY}. Lenient by design (matches
30
+ * the rest of `normalizeRunStart` and the Python reference server).
31
+ */
32
+ export const normalizeMultitaskStrategy = (value) => {
33
+ return typeof value === "string" &&
34
+ VALID_MULTITASK_STRATEGIES.includes(value)
35
+ ? value
36
+ : undefined;
23
37
  };
24
38
  const normalizeRunStart = (value) => {
25
39
  if (isRecord(value)) {
@@ -28,7 +42,7 @@ const normalizeRunStart = (value) => {
28
42
  input: value.input,
29
43
  config: isRecord(value.config) ? value.config : undefined,
30
44
  metadata: isRecord(value.metadata) ? value.metadata : undefined,
31
- forkFrom: normalizeForkFrom(value.forkFrom),
45
+ multitaskStrategy: normalizeMultitaskStrategy(value.multitaskStrategy),
32
46
  };
33
47
  }
34
48
  return {
@@ -206,10 +220,31 @@ export class ProtocolService {
206
220
  };
207
221
  }
208
222
  async handleInputRespond(record, command) {
209
- const params = isRecord(command.params)
223
+ // Build the resume input map (`{ [interrupt_id]: response }`). The
224
+ // SDK sends either a single `interrupt_id` / `response` or a
225
+ // `responses` batch (several interrupts at the same checkpoint, which
226
+ // must be resumed in one command) — the `InputRespondOne` /
227
+ // `InputRespondMany` variants of `InputRespondParams`. Read leniently
228
+ // to tolerate clients pinned to older bindings.
229
+ const rawParams = isRecord(command.params)
210
230
  ? command.params
211
231
  : {};
212
- if (typeof params.interrupt_id !== "string") {
232
+ const resumeInput = {};
233
+ if (Array.isArray(rawParams.responses)) {
234
+ for (const entry of rawParams.responses) {
235
+ if (!isRecord(entry) || typeof entry.interrupt_id !== "string") {
236
+ return this.error(command.id, "invalid_argument", "input.respond responses entries require an interrupt_id.");
237
+ }
238
+ resumeInput[entry.interrupt_id] = entry.response;
239
+ }
240
+ if (Object.keys(resumeInput).length === 0) {
241
+ return this.error(command.id, "invalid_argument", "input.respond requires at least one response.");
242
+ }
243
+ }
244
+ else if (typeof rawParams.interrupt_id === "string") {
245
+ resumeInput[rawParams.interrupt_id] = rawParams.response;
246
+ }
247
+ else {
213
248
  return this.error(command.id, "invalid_argument", "input.respond requires an interrupt_id.");
214
249
  }
215
250
  if (record.assistantId == null) {
@@ -225,11 +260,18 @@ export class ProtocolService {
225
260
  if (!hasPendingInterrupts) {
226
261
  return this.error(command.id, "invalid_argument", "input.respond can only be used while the run is interrupted.");
227
262
  }
263
+ // `config` / `metadata` are part of `InputRespondParams` (both the
264
+ // `InputRespondOne` and `InputRespondMany` variants). The SDK's
265
+ // `respond({ config, metadata })` forwards them on the `input.respond`
266
+ // command so a resumed run can carry the same run config (model, user
267
+ // context, …) and metadata (trigger source, test flags, …) a fresh
268
+ // `run.start` would. Read them leniently — same posture as
269
+ // `normalizeRunStart`.
228
270
  await this.createOrResumeRun(record, {
229
271
  assistant_id: record.assistantId,
230
- input: { [params.interrupt_id]: params.response },
231
- config: undefined,
232
- metadata: undefined,
272
+ input: resumeInput,
273
+ config: isRecord(rawParams.config) ? rawParams.config : undefined,
274
+ metadata: isRecord(rawParams.metadata) ? rawParams.metadata : undefined,
233
275
  });
234
276
  return {
235
277
  type: "success",
@@ -254,22 +296,17 @@ export class ProtocolService {
254
296
  ((currentRun != null && currentStatus === "interrupted") ||
255
297
  hasPendingInterrupts);
256
298
  /**
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.
299
+ * Fork/time-travel replays from `config.configurable.checkpoint_id`
300
+ * when the caller supplies it (the SDK folds its `forkFrom`
301
+ * convenience option into this field client-side). Forwarded as-is so
302
+ * the engine starts from the requested checkpoint instead of the
303
+ * thread's latest state.
264
304
  */
265
305
  const runConfig = {
266
306
  ...params.config,
267
307
  configurable: {
268
308
  ...params.config?.configurable,
269
309
  thread_id: record.threadId,
270
- ...(!isResume && params.forkFrom?.checkpointId != null
271
- ? { checkpoint_id: params.forkFrom.checkpointId }
272
- : {}),
273
310
  },
274
311
  };
275
312
  const runPayload = {
@@ -284,7 +321,10 @@ export class ProtocolService {
284
321
  stream_subgraphs: true,
285
322
  stream_resumable: true,
286
323
  if_not_exists: "create",
287
- multitask_strategy: "interrupt",
324
+ // Honor the caller's `multitaskStrategy` (the SDK sends it on every
325
+ // run.start), falling back to `enqueue` — the legacy stream-endpoint
326
+ // default — when omitted. Matches the Python protocol-v2 server.
327
+ multitask_strategy: params.multitaskStrategy ?? DEFAULT_MULTITASK_STRATEGY,
288
328
  };
289
329
  const [run] = await this.bindings.runs.put(uuid7(), assistantId, {
290
330
  input: runPayload.input,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@langchain/langgraph-api",
3
- "version": "1.2.2-rc.0",
3
+ "version": "1.2.3",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": "^18.19.0 || >=20.16.0"
@@ -55,7 +55,7 @@
55
55
  "@hono/node-server": "^1.19.13",
56
56
  "@hono/node-ws": "^1.3.0",
57
57
  "@hono/zod-validator": "^0.7.6",
58
- "@langchain/protocol": "^0.0.15",
58
+ "@langchain/protocol": "^0.0.16",
59
59
  "@types/json-schema": "^7.0.15",
60
60
  "@typescript/vfs": "^1.6.0",
61
61
  "dedent": "^1.5.3",
@@ -72,7 +72,7 @@
72
72
  "winston": "^3.17.0",
73
73
  "winston-console-format": "^1.0.8",
74
74
  "zod": "^3.25.76 || ^4",
75
- "@langchain/langgraph-ui": "1.2.2-rc.0"
75
+ "@langchain/langgraph-ui": "1.2.3"
76
76
  },
77
77
  "peerDependencies": {
78
78
  "@langchain/core": "^1.1.44",
@@ -101,9 +101,9 @@
101
101
  "typescript": "^4.9.5 || ^5.4.5",
102
102
  "vitest": "^3.2.4",
103
103
  "wait-port": "^1.1.0",
104
- "@langchain/langgraph": "1.3.1-rc.0",
105
- "@langchain/langgraph-checkpoint": "1.0.2",
106
- "@langchain/langgraph-sdk": "1.9.3-rc.0"
104
+ "@langchain/langgraph": "1.3.2",
105
+ "@langchain/langgraph-checkpoint": "1.0.3",
106
+ "@langchain/langgraph-sdk": "1.9.10"
107
107
  },
108
108
  "scripts": {
109
109
  "clean": "rm -rf dist/ .turbo/ ./tests/graphs/.langgraph_api/ ./tests/protocol-v2/graphs/.langgraph_api/",