@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
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
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
|
-
|
|
218
|
-
|
|
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:
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
105
|
-
"@langchain/langgraph-checkpoint": "1.0.
|
|
106
|
-
"@langchain/langgraph-sdk": "1.9.
|
|
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/",
|