@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.
- package/dist/src/api/assistants.d.mts +3 -0
- package/dist/src/api/assistants.mjs +194 -0
- package/dist/src/api/meta.d.mts +3 -0
- package/dist/src/api/meta.mjs +65 -0
- package/dist/src/api/protocol.d.mts +7 -0
- package/dist/src/api/protocol.mjs +157 -0
- package/dist/src/api/runs.d.mts +3 -0
- package/dist/src/api/runs.mjs +335 -0
- package/dist/src/api/store.d.mts +3 -0
- package/dist/src/api/store.mjs +111 -0
- package/dist/src/api/threads.d.mts +3 -0
- package/dist/src/api/threads.mjs +143 -0
- package/dist/src/graph/load.utils.d.mts +22 -0
- package/dist/src/graph/load.utils.mjs +59 -0
- package/dist/src/protocol/service.d.mts +101 -0
- package/dist/src/protocol/service.mjs +568 -0
- package/dist/src/protocol/session/event-normalizers.d.mts +52 -0
- package/dist/src/protocol/session/index.d.mts +261 -0
- package/dist/src/protocol/session/index.mjs +826 -0
- package/dist/src/protocol/session/namespace.d.mts +47 -0
- package/dist/src/protocol/session/namespace.mjs +62 -0
- package/dist/src/protocol/types.d.mts +121 -0
- package/dist/src/protocol/types.mjs +1 -0
- package/dist/src/schemas.d.mts +1552 -0
- package/dist/src/semver/index.d.mts +15 -0
- package/dist/src/semver/index.mjs +46 -0
- package/dist/src/semver/satisfiesPeerRange.d.mts +1 -0
- package/dist/src/semver/satisfiesPeerRange.mjs +19 -0
- package/dist/src/state.d.mts +3 -0
- package/dist/src/state.mjs +30 -0
- package/dist/src/storage/context.d.mts +3 -0
- package/dist/src/storage/context.mjs +11 -0
- package/dist/src/storage/ops.mjs +1281 -0
- package/dist/src/stream.d.mts +64 -0
- package/dist/src/stream.mjs +427 -0
- package/dist/src/utils/hono.d.mts +5 -0
- package/dist/src/utils/hono.mjs +24 -0
- package/dist/src/utils/runnableConfig.d.mts +3 -0
- package/dist/src/utils/runnableConfig.mjs +45 -0
- package/dist/src/webhook.d.mts +11 -0
- package/dist/src/webhook.mjs +30 -0
- 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
|
+
}
|