@langchain/langgraph-api 1.1.17 → 1.2.0
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/api/protocol.d.mts +7 -0
- package/dist/api/protocol.mjs +157 -0
- package/dist/api/runs.mjs +15 -4
- package/dist/command.mjs +1 -1
- package/dist/experimental/embed/constants.d.mts +3 -0
- package/dist/experimental/embed/constants.mjs +11 -0
- package/dist/experimental/embed/protocol.d.mts +9 -0
- package/dist/experimental/embed/protocol.mjs +453 -0
- package/dist/experimental/embed/runs.d.mts +8 -0
- package/dist/experimental/embed/runs.mjs +202 -0
- package/dist/experimental/embed/threads.d.mts +8 -0
- package/dist/experimental/embed/threads.mjs +151 -0
- package/dist/experimental/embed/types.d.mts +77 -0
- package/dist/experimental/embed/types.mjs +1 -0
- package/dist/experimental/embed/utils.d.mts +29 -0
- package/dist/experimental/embed/utils.mjs +62 -0
- package/dist/experimental/embed.d.mts +11 -31
- package/dist/experimental/embed.mjs +19 -399
- package/dist/graph/load.d.mts +2 -2
- package/dist/graph/load.utils.mjs +13 -3
- package/dist/protocol/constants.d.mts +7 -0
- package/dist/protocol/constants.mjs +7 -0
- package/dist/protocol/service.d.mts +101 -0
- package/dist/protocol/service.mjs +568 -0
- package/dist/protocol/session/event-normalizers.d.mts +52 -0
- package/dist/protocol/session/event-normalizers.mjs +162 -0
- package/dist/protocol/session/index.d.mts +261 -0
- package/dist/protocol/session/index.mjs +826 -0
- package/dist/protocol/session/internal-types.d.mts +67 -0
- package/dist/protocol/session/internal-types.mjs +28 -0
- package/dist/protocol/session/metadata.d.mts +24 -0
- package/dist/protocol/session/metadata.mjs +95 -0
- package/dist/protocol/session/namespace.d.mts +47 -0
- package/dist/protocol/session/namespace.mjs +62 -0
- package/dist/protocol/session/state-normalizers.d.mts +57 -0
- package/dist/protocol/session/state-normalizers.mjs +430 -0
- package/dist/protocol/session/tool-calls.d.mts +27 -0
- package/dist/protocol/session/tool-calls.mjs +59 -0
- package/dist/protocol/types.d.mts +121 -0
- package/dist/protocol/types.mjs +1 -0
- package/dist/queue.mjs +8 -2
- package/dist/schemas.d.mts +58 -58
- package/dist/semver/index.mjs +19 -1
- package/dist/server.mjs +22 -3
- package/dist/state.mjs +1 -1
- package/dist/storage/ops.mjs +17 -5
- package/dist/storage/persist.mjs +1 -1
- package/dist/storage/types.d.mts +10 -1
- package/dist/stream.d.mts +25 -4
- package/dist/stream.mjs +203 -11
- package/dist/utils/serde.mjs +1 -1
- package/package.json +16 -14
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
import { zValidator } from "@hono/zod-validator";
|
|
2
|
+
import { streamSSE } from "hono/streaming";
|
|
3
|
+
import { v7 as uuidv7 } from "uuid";
|
|
4
|
+
import { z } from "zod/v3";
|
|
5
|
+
import { streamState } from "../../stream.mjs";
|
|
6
|
+
import { serialiseAsDict } from "../../utils/serde.mjs";
|
|
7
|
+
import { jsonExtra } from "../../utils/hono.mjs";
|
|
8
|
+
import { RunProtocolSession } from "../../protocol/session/index.mjs";
|
|
9
|
+
import { PROTOCOL_STREAM_RUN_KEY } from "../../protocol/constants.mjs";
|
|
10
|
+
import { matchesSinkFilter } from "../../protocol/service.mjs";
|
|
11
|
+
import { ProtocolCommandSchema, ThreadIdSchema, isRecord, createStubRun, } from "./utils.mjs";
|
|
12
|
+
import { DEFAULT_PROTOCOL_STREAM_MODES } from "./constants.mjs";
|
|
13
|
+
const EventsFilterSchema = z
|
|
14
|
+
.object({
|
|
15
|
+
channels: z.array(z.string()),
|
|
16
|
+
namespaces: z.array(z.array(z.string())).optional(),
|
|
17
|
+
depth: z.number().int().nonnegative().optional(),
|
|
18
|
+
since: z.number().int().nonnegative().optional(),
|
|
19
|
+
})
|
|
20
|
+
.strict();
|
|
21
|
+
/**
|
|
22
|
+
* Normalize browser/node websocket payloads into UTF-8 text so the
|
|
23
|
+
* protocol layer only needs to handle JSON strings.
|
|
24
|
+
*/
|
|
25
|
+
const parseSocketPayload = async (event) => {
|
|
26
|
+
if (typeof event.data === "string")
|
|
27
|
+
return event.data;
|
|
28
|
+
if (event.data instanceof ArrayBuffer) {
|
|
29
|
+
return new TextDecoder().decode(new Uint8Array(event.data));
|
|
30
|
+
}
|
|
31
|
+
if (event.data instanceof Blob) {
|
|
32
|
+
const buffer = await event.data.arrayBuffer();
|
|
33
|
+
return new TextDecoder().decode(new Uint8Array(buffer));
|
|
34
|
+
}
|
|
35
|
+
return String(event.data);
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Register thread-centric v2 protocol routes on an embed server Hono app.
|
|
39
|
+
*
|
|
40
|
+
* @experimental Does not follow semver.
|
|
41
|
+
*/
|
|
42
|
+
export function registerProtocolRoutes(api, context, upgradeWebSocket) {
|
|
43
|
+
const threads = new Map();
|
|
44
|
+
function ensureThread(threadId) {
|
|
45
|
+
let thread = threads.get(threadId);
|
|
46
|
+
if (thread == null) {
|
|
47
|
+
thread = {
|
|
48
|
+
threadId,
|
|
49
|
+
seq: 0,
|
|
50
|
+
eventSinks: new Map(),
|
|
51
|
+
queuedEvents: [],
|
|
52
|
+
};
|
|
53
|
+
threads.set(threadId, thread);
|
|
54
|
+
}
|
|
55
|
+
return thread;
|
|
56
|
+
}
|
|
57
|
+
async function* trackRunStatus(source, run) {
|
|
58
|
+
try {
|
|
59
|
+
yield* source;
|
|
60
|
+
run.status = "success";
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
run.status = "error";
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function attachRunSession(thread, run) {
|
|
68
|
+
const rawSource = streamState(run, {
|
|
69
|
+
attempt: 1,
|
|
70
|
+
getGraph: context.getGraph,
|
|
71
|
+
signal: undefined,
|
|
72
|
+
});
|
|
73
|
+
const source = trackRunStatus(rawSource, run);
|
|
74
|
+
const protocolSession = new RunProtocolSession({
|
|
75
|
+
runId: run.run_id,
|
|
76
|
+
threadId: thread.threadId,
|
|
77
|
+
initialRun: run,
|
|
78
|
+
getRun: async () => thread.currentRun ?? null,
|
|
79
|
+
getThreadState: async () => {
|
|
80
|
+
const persisted = await context.threads.get(thread.threadId);
|
|
81
|
+
const graphId = persisted?.metadata?.graph_id;
|
|
82
|
+
if (!graphId)
|
|
83
|
+
return null;
|
|
84
|
+
const graph = await context.getGraph(graphId);
|
|
85
|
+
const snapshot = await graph.getState({ configurable: { thread_id: thread.threadId } }, { subgraphs: true });
|
|
86
|
+
return {
|
|
87
|
+
tasks: snapshot.tasks.map((t) => ({
|
|
88
|
+
interrupts: t.interrupts,
|
|
89
|
+
})),
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
source,
|
|
93
|
+
startSeq: thread.seq,
|
|
94
|
+
passthrough: true,
|
|
95
|
+
send: async (payload) => {
|
|
96
|
+
const parsed = JSON.parse(payload);
|
|
97
|
+
thread.seq = Math.max(thread.seq, parsed.seq ?? thread.seq);
|
|
98
|
+
// Always buffer events so late-attaching sinks can replay
|
|
99
|
+
// matching history. Sinks with `pendingReplay` are skipped
|
|
100
|
+
// here; their replay loop delivers this event in buffer order.
|
|
101
|
+
thread.queuedEvents.push(parsed);
|
|
102
|
+
for (const sink of thread.eventSinks.values()) {
|
|
103
|
+
if (sink.pendingReplay)
|
|
104
|
+
continue;
|
|
105
|
+
if (sink.unfiltered || matchesSinkFilter(sink.filter, parsed)) {
|
|
106
|
+
await sink.send(parsed);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
thread.runSession = protocolSession;
|
|
112
|
+
thread.currentRun = run;
|
|
113
|
+
return protocolSession;
|
|
114
|
+
}
|
|
115
|
+
async function handleRunStart(thread, command) {
|
|
116
|
+
const params = isRecord(command.params)
|
|
117
|
+
? command.params
|
|
118
|
+
: {};
|
|
119
|
+
const assistantId = typeof params.assistant_id === "string" ? params.assistant_id : undefined;
|
|
120
|
+
if (!assistantId) {
|
|
121
|
+
return jsonResponse({
|
|
122
|
+
type: "error",
|
|
123
|
+
id: command.id,
|
|
124
|
+
error: "invalid_argument",
|
|
125
|
+
message: "run.start requires an assistant_id.",
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
if (thread.assistantId != null && thread.assistantId !== assistantId) {
|
|
129
|
+
return jsonResponse({
|
|
130
|
+
type: "error",
|
|
131
|
+
id: command.id,
|
|
132
|
+
error: "invalid_argument",
|
|
133
|
+
message: `Thread ${thread.threadId} is bound to assistant ${thread.assistantId}; cannot run ${assistantId}.`,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
thread.assistantId = assistantId;
|
|
137
|
+
const runMetadata = isRecord(params.metadata)
|
|
138
|
+
? params.metadata
|
|
139
|
+
: {};
|
|
140
|
+
// Lazily create the persisted thread on first use.
|
|
141
|
+
let persisted = null;
|
|
142
|
+
try {
|
|
143
|
+
persisted = await context.threads.get(thread.threadId);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
persisted = null;
|
|
147
|
+
}
|
|
148
|
+
if (persisted == null) {
|
|
149
|
+
await context.threads.set(thread.threadId, {
|
|
150
|
+
kind: "put",
|
|
151
|
+
metadata: {
|
|
152
|
+
...runMetadata,
|
|
153
|
+
graph_id: assistantId,
|
|
154
|
+
assistant_id: assistantId,
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
await context.threads.set(thread.threadId, {
|
|
160
|
+
kind: "patch",
|
|
161
|
+
metadata: {
|
|
162
|
+
...runMetadata,
|
|
163
|
+
graph_id: assistantId,
|
|
164
|
+
assistant_id: assistantId,
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
}
|
|
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.
|
|
173
|
+
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;
|
|
178
|
+
})();
|
|
179
|
+
const run = createStubRun(thread.threadId, {
|
|
180
|
+
assistant_id: assistantId,
|
|
181
|
+
on_disconnect: "cancel",
|
|
182
|
+
input: params.input ?? null,
|
|
183
|
+
config: {
|
|
184
|
+
configurable: {
|
|
185
|
+
...(isRecord(params.config) && isRecord(params.config.configurable)
|
|
186
|
+
? params.config.configurable
|
|
187
|
+
: {}),
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
// `createStubRun` promotes the top-level `checkpoint_id` into
|
|
191
|
+
// `config.configurable.checkpoint_id` after merging (and, in
|
|
192
|
+
// fact, *replaces* any inline configurable the caller passed),
|
|
193
|
+
// so this is the only reliable way to reach the engine with a
|
|
194
|
+
// fork target.
|
|
195
|
+
...(forkCheckpointId != null ? { checkpoint_id: forkCheckpointId } : {}),
|
|
196
|
+
metadata: Object.keys(runMetadata).length > 0 ? runMetadata : undefined,
|
|
197
|
+
stream_mode: DEFAULT_PROTOCOL_STREAM_MODES,
|
|
198
|
+
stream_subgraphs: true,
|
|
199
|
+
});
|
|
200
|
+
run.kwargs[PROTOCOL_STREAM_RUN_KEY] = true;
|
|
201
|
+
const protocolSession = attachRunSession(thread, run);
|
|
202
|
+
await protocolSession.start();
|
|
203
|
+
return jsonResponse({
|
|
204
|
+
type: "success",
|
|
205
|
+
id: command.id,
|
|
206
|
+
result: { run_id: run.run_id },
|
|
207
|
+
meta: {
|
|
208
|
+
thread_id: thread.threadId,
|
|
209
|
+
applied_through_seq: thread.seq,
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
async function handleInputRespond(thread, command) {
|
|
214
|
+
const params = isRecord(command.params)
|
|
215
|
+
? command.params
|
|
216
|
+
: {};
|
|
217
|
+
const interruptId = params.interrupt_id;
|
|
218
|
+
if (typeof interruptId !== "string") {
|
|
219
|
+
return jsonResponse({
|
|
220
|
+
type: "error",
|
|
221
|
+
id: command.id,
|
|
222
|
+
error: "invalid_argument",
|
|
223
|
+
message: "input.respond requires an interrupt_id.",
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
const assistantId = thread.assistantId;
|
|
227
|
+
if (assistantId == null) {
|
|
228
|
+
return jsonResponse({
|
|
229
|
+
type: "error",
|
|
230
|
+
id: command.id,
|
|
231
|
+
error: "no_such_run",
|
|
232
|
+
message: "Thread has no active assistant; call run.start first.",
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
const run = createStubRun(thread.threadId, {
|
|
236
|
+
assistant_id: assistantId,
|
|
237
|
+
on_disconnect: "cancel",
|
|
238
|
+
input: null,
|
|
239
|
+
command: { resume: { [interruptId]: params.response } },
|
|
240
|
+
stream_mode: DEFAULT_PROTOCOL_STREAM_MODES,
|
|
241
|
+
stream_subgraphs: true,
|
|
242
|
+
});
|
|
243
|
+
run.kwargs[PROTOCOL_STREAM_RUN_KEY] = true;
|
|
244
|
+
// Drop the previous run's buffered events so that a sink attaching
|
|
245
|
+
// *after* this resume does not replay stale terminal lifecycle
|
|
246
|
+
// events (e.g. `lifecycle.interrupted`) from the paused run. Any
|
|
247
|
+
// sink that was already receiving live events is unaffected.
|
|
248
|
+
thread.queuedEvents.length = 0;
|
|
249
|
+
const protocolSession = attachRunSession(thread, run);
|
|
250
|
+
await protocolSession.start();
|
|
251
|
+
return jsonResponse({
|
|
252
|
+
type: "success",
|
|
253
|
+
id: command.id,
|
|
254
|
+
result: {},
|
|
255
|
+
meta: {
|
|
256
|
+
thread_id: thread.threadId,
|
|
257
|
+
applied_through_seq: thread.seq,
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
async function handleThreadCommand(thread, command) {
|
|
262
|
+
if (command.method === "run.start") {
|
|
263
|
+
return await handleRunStart(thread, command);
|
|
264
|
+
}
|
|
265
|
+
if (command.method === "input.respond") {
|
|
266
|
+
return await handleInputRespond(thread, command);
|
|
267
|
+
}
|
|
268
|
+
// WebSocket transports send `subscription.subscribe`/`unsubscribe`
|
|
269
|
+
// over the same socket before any run is bound. The embed server
|
|
270
|
+
// delivers *all* events to the WS sink and relies on the SDK to
|
|
271
|
+
// filter client-side, so we can accept these commands without
|
|
272
|
+
// additional bookkeeping.
|
|
273
|
+
if (command.method === "subscription.subscribe") {
|
|
274
|
+
const subscriptionId = uuidv7();
|
|
275
|
+
return jsonResponse({
|
|
276
|
+
type: "success",
|
|
277
|
+
id: command.id,
|
|
278
|
+
result: { subscription_id: subscriptionId },
|
|
279
|
+
meta: {
|
|
280
|
+
thread_id: thread.threadId,
|
|
281
|
+
applied_through_seq: thread.seq,
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
if (command.method === "subscription.unsubscribe") {
|
|
286
|
+
return jsonResponse({
|
|
287
|
+
type: "success",
|
|
288
|
+
id: command.id,
|
|
289
|
+
result: {},
|
|
290
|
+
meta: {
|
|
291
|
+
thread_id: thread.threadId,
|
|
292
|
+
applied_through_seq: thread.seq,
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
if (thread.runSession == null) {
|
|
297
|
+
return jsonResponse({
|
|
298
|
+
type: "error",
|
|
299
|
+
id: command.id,
|
|
300
|
+
error: "no_such_run",
|
|
301
|
+
message: "No active run is bound to this thread.",
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
return jsonResponse(await thread.runSession.handleProtocolCommand(command, {
|
|
305
|
+
thread_id: thread.threadId,
|
|
306
|
+
applied_through_seq: thread.seq,
|
|
307
|
+
}));
|
|
308
|
+
}
|
|
309
|
+
function jsonResponse(body) {
|
|
310
|
+
return new Response(serialiseAsDict(body), {
|
|
311
|
+
headers: { "Content-Type": "application/json" },
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
api.post("/v2/threads/:thread_id/commands", zValidator("param", ThreadIdSchema), zValidator("json", ProtocolCommandSchema), async (c) => {
|
|
315
|
+
const { thread_id } = c.req.valid("param");
|
|
316
|
+
const thread = ensureThread(thread_id);
|
|
317
|
+
const command = c.req.valid("json");
|
|
318
|
+
return await handleThreadCommand(thread, command);
|
|
319
|
+
});
|
|
320
|
+
api.post("/v2/threads/:thread_id/stream", zValidator("param", ThreadIdSchema), zValidator("json", EventsFilterSchema), async (c) => {
|
|
321
|
+
const { thread_id } = c.req.valid("param");
|
|
322
|
+
const thread = ensureThread(thread_id);
|
|
323
|
+
const body = c.req.valid("json");
|
|
324
|
+
const sinkId = uuidv7();
|
|
325
|
+
const filter = {
|
|
326
|
+
channels: new Set(body.channels),
|
|
327
|
+
namespaces: body.namespaces,
|
|
328
|
+
depth: body.depth,
|
|
329
|
+
since: body.since,
|
|
330
|
+
};
|
|
331
|
+
return streamSSE(c, async (stream) => {
|
|
332
|
+
const delivered = new Set();
|
|
333
|
+
const writeSse = async (event) => {
|
|
334
|
+
if (event.event_id == null)
|
|
335
|
+
return;
|
|
336
|
+
if (delivered.has(event.event_id))
|
|
337
|
+
return;
|
|
338
|
+
delivered.add(event.event_id);
|
|
339
|
+
await stream.writeSSE({
|
|
340
|
+
id: event.event_id,
|
|
341
|
+
event: event.method,
|
|
342
|
+
data: serialiseAsDict(event),
|
|
343
|
+
});
|
|
344
|
+
};
|
|
345
|
+
// Register the sink as replaying so the live `send` path skips
|
|
346
|
+
// it while we drain buffered events in order. A cursor-based
|
|
347
|
+
// drain catches any events pushed during our awaits, then we
|
|
348
|
+
// unblock live delivery.
|
|
349
|
+
const sink = {
|
|
350
|
+
id: sinkId,
|
|
351
|
+
filter,
|
|
352
|
+
send: writeSse,
|
|
353
|
+
pendingReplay: true,
|
|
354
|
+
};
|
|
355
|
+
thread.eventSinks.set(sinkId, sink);
|
|
356
|
+
try {
|
|
357
|
+
let cursor = 0;
|
|
358
|
+
while (cursor < thread.queuedEvents.length) {
|
|
359
|
+
const event = thread.queuedEvents[cursor++];
|
|
360
|
+
if (matchesSinkFilter(filter, event)) {
|
|
361
|
+
await writeSse(event);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
finally {
|
|
366
|
+
sink.pendingReplay = false;
|
|
367
|
+
}
|
|
368
|
+
stream.onAbort(() => {
|
|
369
|
+
thread.eventSinks.delete(sinkId);
|
|
370
|
+
});
|
|
371
|
+
await new Promise((resolve) => {
|
|
372
|
+
stream.onAbort(() => resolve());
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
if (upgradeWebSocket != null) {
|
|
377
|
+
api.get("/v2/threads/:thread_id/stream", zValidator("param", ThreadIdSchema), upgradeWebSocket((c) => {
|
|
378
|
+
const { thread_id } = c.req.valid("param");
|
|
379
|
+
const thread = ensureThread(thread_id);
|
|
380
|
+
const sinkId = uuidv7();
|
|
381
|
+
return {
|
|
382
|
+
async onOpen(_event, ws) {
|
|
383
|
+
// Unfiltered sink: forward every buffered + live event to the
|
|
384
|
+
// websocket in order, so the browser ThreadStream sees the
|
|
385
|
+
// complete event history regardless of subscription timing.
|
|
386
|
+
const writeWs = async (event) => {
|
|
387
|
+
ws.send(serialiseAsDict(event));
|
|
388
|
+
};
|
|
389
|
+
const sink = {
|
|
390
|
+
id: sinkId,
|
|
391
|
+
filter: {
|
|
392
|
+
channels: new Set(),
|
|
393
|
+
namespaces: undefined,
|
|
394
|
+
depth: undefined,
|
|
395
|
+
since: undefined,
|
|
396
|
+
},
|
|
397
|
+
send: writeWs,
|
|
398
|
+
pendingReplay: true,
|
|
399
|
+
unfiltered: true,
|
|
400
|
+
};
|
|
401
|
+
thread.eventSinks.set(sinkId, sink);
|
|
402
|
+
try {
|
|
403
|
+
let cursor = 0;
|
|
404
|
+
while (cursor < thread.queuedEvents.length) {
|
|
405
|
+
await writeWs(thread.queuedEvents[cursor++]);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
finally {
|
|
409
|
+
sink.pendingReplay = false;
|
|
410
|
+
}
|
|
411
|
+
},
|
|
412
|
+
async onMessage(event, ws) {
|
|
413
|
+
let payload;
|
|
414
|
+
try {
|
|
415
|
+
payload = JSON.parse(await parseSocketPayload(event));
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
ws.send(serialiseAsDict({
|
|
419
|
+
type: "error",
|
|
420
|
+
id: null,
|
|
421
|
+
error: "invalid_argument",
|
|
422
|
+
message: "Protocol commands must be valid JSON.",
|
|
423
|
+
}));
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
if (typeof payload !== "object" ||
|
|
427
|
+
payload == null ||
|
|
428
|
+
typeof payload.id !== "number" ||
|
|
429
|
+
typeof payload.method !== "string") {
|
|
430
|
+
ws.send(serialiseAsDict({
|
|
431
|
+
type: "error",
|
|
432
|
+
id: null,
|
|
433
|
+
error: "invalid_argument",
|
|
434
|
+
message: "Protocol commands must include an integer id and string method.",
|
|
435
|
+
}));
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
const response = await handleThreadCommand(thread, payload);
|
|
439
|
+
const body = await response.text();
|
|
440
|
+
ws.send(body);
|
|
441
|
+
},
|
|
442
|
+
onClose() {
|
|
443
|
+
thread.eventSinks.delete(sinkId);
|
|
444
|
+
},
|
|
445
|
+
onError() {
|
|
446
|
+
thread.eventSinks.delete(sinkId);
|
|
447
|
+
},
|
|
448
|
+
};
|
|
449
|
+
}));
|
|
450
|
+
}
|
|
451
|
+
// jsonExtra is no longer needed but keep import indirection minimal.
|
|
452
|
+
void jsonExtra;
|
|
453
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Hono } from "hono";
|
|
2
|
+
import type { EmbedRouteContext } from "./types.mjs";
|
|
3
|
+
/**
|
|
4
|
+
* Register run creation and streaming routes on an embed server Hono app.
|
|
5
|
+
*
|
|
6
|
+
* @experimental Does not follow semver.
|
|
7
|
+
*/
|
|
8
|
+
export declare function registerRunRoutes(api: Hono, context: EmbedRouteContext): void;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { zValidator } from "@hono/zod-validator";
|
|
2
|
+
import { streamSSE } from "hono/streaming";
|
|
3
|
+
import { v7 as uuidv7 } from "uuid";
|
|
4
|
+
import { z } from "zod/v3";
|
|
5
|
+
import * as schemas from "../../schemas.mjs";
|
|
6
|
+
import { streamState } from "../../stream.mjs";
|
|
7
|
+
import { serialiseAsDict, serializeError } from "../../utils/serde.mjs";
|
|
8
|
+
import { getDisconnectAbortSignal, jsonExtra } from "../../utils/hono.mjs";
|
|
9
|
+
import { createStubRun } from "./utils.mjs";
|
|
10
|
+
/**
|
|
11
|
+
* Register run creation and streaming routes on an embed server Hono app.
|
|
12
|
+
*
|
|
13
|
+
* @experimental Does not follow semver.
|
|
14
|
+
*/
|
|
15
|
+
export function registerRunRoutes(api, context) {
|
|
16
|
+
const threadRunState = new Map();
|
|
17
|
+
function getThreadState(threadId) {
|
|
18
|
+
let state = threadRunState.get(threadId);
|
|
19
|
+
if (!state) {
|
|
20
|
+
state = { activeRunId: null, pendingRuns: [] };
|
|
21
|
+
threadRunState.set(threadId, state);
|
|
22
|
+
}
|
|
23
|
+
return state;
|
|
24
|
+
}
|
|
25
|
+
async function waitForRunReady(threadId, runId, signal) {
|
|
26
|
+
const state = getThreadState(threadId);
|
|
27
|
+
const run = state.pendingRuns.find((r) => r.run_id === runId);
|
|
28
|
+
if (!run)
|
|
29
|
+
return null;
|
|
30
|
+
while (true) {
|
|
31
|
+
if (signal?.aborted) {
|
|
32
|
+
throw new DOMException("Aborted", "AbortError");
|
|
33
|
+
}
|
|
34
|
+
const isHead = state.pendingRuns[0]?.run_id === runId;
|
|
35
|
+
const noActive = !state.activeRunId;
|
|
36
|
+
if (isHead && noActive)
|
|
37
|
+
break;
|
|
38
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
39
|
+
}
|
|
40
|
+
return run;
|
|
41
|
+
}
|
|
42
|
+
api.post("/threads/:thread_id/runs", zValidator("param", z.object({ thread_id: z.string().uuid() })), zValidator("json", schemas.RunCreate), async (c) => {
|
|
43
|
+
const { thread_id } = c.req.valid("param");
|
|
44
|
+
const payload = c.req.valid("json");
|
|
45
|
+
const thread = await context.threads.get(thread_id);
|
|
46
|
+
if (thread == null)
|
|
47
|
+
return c.json({ error: "Thread not found" }, 404);
|
|
48
|
+
const state = getThreadState(thread_id);
|
|
49
|
+
const multitaskStrategy = payload.multitask_strategy ?? "reject";
|
|
50
|
+
const shouldEnqueue = multitaskStrategy === "enqueue" && state.activeRunId != null;
|
|
51
|
+
const run = createStubRun(thread_id, payload, {
|
|
52
|
+
status: shouldEnqueue ? "pending" : "running",
|
|
53
|
+
multitask_strategy: multitaskStrategy,
|
|
54
|
+
});
|
|
55
|
+
state.pendingRuns.push(run);
|
|
56
|
+
c.header("Content-Location", `/threads/${thread_id}/runs/${run.run_id}`);
|
|
57
|
+
return jsonExtra(c, run);
|
|
58
|
+
});
|
|
59
|
+
api.get("/threads/:thread_id/runs/:run_id/stream", zValidator("param", z.object({
|
|
60
|
+
thread_id: z.string().uuid(),
|
|
61
|
+
run_id: z.string().uuid(),
|
|
62
|
+
})), async (c) => {
|
|
63
|
+
const { thread_id, run_id } = c.req.valid("param");
|
|
64
|
+
const thread = await context.threads.get(thread_id);
|
|
65
|
+
if (thread == null)
|
|
66
|
+
return c.json({ error: "Thread not found" }, 404);
|
|
67
|
+
return streamSSE(c, async (stream) => {
|
|
68
|
+
const signal = getDisconnectAbortSignal(c, stream);
|
|
69
|
+
const state = getThreadState(thread_id);
|
|
70
|
+
try {
|
|
71
|
+
const run = await waitForRunReady(thread_id, run_id, signal);
|
|
72
|
+
if (!run) {
|
|
73
|
+
await stream.writeSSE({
|
|
74
|
+
data: serialiseAsDict({ error: "Run not found" }),
|
|
75
|
+
event: "error",
|
|
76
|
+
});
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const idx = state.pendingRuns.findIndex((r) => r.run_id === run_id);
|
|
80
|
+
if (idx >= 0)
|
|
81
|
+
state.pendingRuns.splice(idx, 1);
|
|
82
|
+
state.activeRunId = run_id;
|
|
83
|
+
run.status = "running";
|
|
84
|
+
try {
|
|
85
|
+
for await (const { event, data } of streamState(run, {
|
|
86
|
+
attempt: 1,
|
|
87
|
+
getGraph: context.getGraph,
|
|
88
|
+
signal,
|
|
89
|
+
})) {
|
|
90
|
+
await stream.writeSSE({ data: serialiseAsDict(data), event });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
await stream.writeSSE({
|
|
95
|
+
data: serialiseAsDict(serializeError(error)),
|
|
96
|
+
event: "error",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
if (state.activeRunId === run_id) {
|
|
101
|
+
state.activeRunId = null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
if (err instanceof DOMException && err.name === "AbortError") {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
api.post("/threads/:thread_id/runs/:run_id/cancel", zValidator("param", z.object({
|
|
114
|
+
thread_id: z.string().uuid(),
|
|
115
|
+
run_id: z.string().uuid(),
|
|
116
|
+
})), async (c) => {
|
|
117
|
+
const { thread_id, run_id } = c.req.valid("param");
|
|
118
|
+
const state = getThreadState(thread_id);
|
|
119
|
+
const idx = state.pendingRuns.findIndex((r) => r.run_id === run_id);
|
|
120
|
+
if (idx >= 0) {
|
|
121
|
+
state.pendingRuns.splice(idx, 1);
|
|
122
|
+
}
|
|
123
|
+
return new Response(null, { status: 204 });
|
|
124
|
+
});
|
|
125
|
+
api.post("/threads/:thread_id/runs/stream", zValidator("param", z.object({ thread_id: z.string().uuid() })), zValidator("json", schemas.RunCreate), async (c) => {
|
|
126
|
+
const { thread_id } = c.req.valid("param");
|
|
127
|
+
const payload = c.req.valid("json");
|
|
128
|
+
const thread = await context.threads.get(thread_id);
|
|
129
|
+
if (thread == null)
|
|
130
|
+
return c.json({ error: "Thread not found" }, 404);
|
|
131
|
+
const state = getThreadState(thread_id);
|
|
132
|
+
const run = createStubRun(thread_id, payload);
|
|
133
|
+
c.header("Content-Location", `/threads/${thread_id}/runs/${run.run_id}`);
|
|
134
|
+
return streamSSE(c, async (stream) => {
|
|
135
|
+
const signal = getDisconnectAbortSignal(c, stream);
|
|
136
|
+
state.activeRunId = run.run_id;
|
|
137
|
+
await context.threads.set(thread_id, {
|
|
138
|
+
kind: "patch",
|
|
139
|
+
metadata: {
|
|
140
|
+
graph_id: payload.assistant_id,
|
|
141
|
+
assistant_id: payload.assistant_id,
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
try {
|
|
145
|
+
for await (const { event, data } of streamState(run, {
|
|
146
|
+
attempt: 1,
|
|
147
|
+
getGraph: context.getGraph,
|
|
148
|
+
signal,
|
|
149
|
+
})) {
|
|
150
|
+
await stream.writeSSE({ data: serialiseAsDict(data), event });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
await stream.writeSSE({
|
|
155
|
+
data: serialiseAsDict(serializeError(error)),
|
|
156
|
+
event: "error",
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
if (state.activeRunId === run.run_id) {
|
|
161
|
+
state.activeRunId = null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
api.post("/runs/stream", zValidator("json", schemas.RunCreate), async (c) => {
|
|
167
|
+
const payload = c.req.valid("json");
|
|
168
|
+
const threadId = uuidv7();
|
|
169
|
+
const run = createStubRun(threadId, payload);
|
|
170
|
+
c.header("Content-Location", `/threads/${threadId}/runs/${run.run_id}`);
|
|
171
|
+
return streamSSE(c, async (stream) => {
|
|
172
|
+
const signal = getDisconnectAbortSignal(c, stream);
|
|
173
|
+
await context.threads.set(threadId, {
|
|
174
|
+
kind: "put",
|
|
175
|
+
metadata: {
|
|
176
|
+
graph_id: payload.assistant_id,
|
|
177
|
+
assistant_id: payload.assistant_id,
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
try {
|
|
181
|
+
try {
|
|
182
|
+
for await (const { event, data } of streamState(run, {
|
|
183
|
+
attempt: 1,
|
|
184
|
+
getGraph: context.getGraph,
|
|
185
|
+
signal,
|
|
186
|
+
})) {
|
|
187
|
+
await stream.writeSSE({ data: serialiseAsDict(data), event });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
await stream.writeSSE({
|
|
192
|
+
data: serialiseAsDict(serializeError(error)),
|
|
193
|
+
event: "error",
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
await context.threads.delete(threadId);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Hono } from "hono";
|
|
2
|
+
import type { EmbedRouteContext } from "./types.mjs";
|
|
3
|
+
/**
|
|
4
|
+
* Register thread CRUD and state routes on an embed server Hono app.
|
|
5
|
+
*
|
|
6
|
+
* @experimental Does not follow semver.
|
|
7
|
+
*/
|
|
8
|
+
export declare function registerThreadRoutes(api: Hono, context: EmbedRouteContext): void;
|