@tangle-network/agent-app 0.47.4 → 0.47.6

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.
@@ -0,0 +1,1049 @@
1
+ import {
2
+ createInteractionAnswerRoute
3
+ } from "./chunk-ZSSCVT6H.js";
4
+ import {
5
+ parseJsonObjectBody
6
+ } from "./chunk-F7VH4G5C.js";
7
+ import {
8
+ mentionInputToPart,
9
+ toChatMessageParts
10
+ } from "./chunk-IRYULX3R.js";
11
+ import {
12
+ ChatTurnInputError,
13
+ assertPromptPartsWithinCap,
14
+ chatTurnRequestInit,
15
+ parseChatTurnParts,
16
+ parseFileMentions
17
+ } from "./chunk-DXHUQ5NO.js";
18
+ import {
19
+ normalizeClientTurnId,
20
+ resolveChatTurn
21
+ } from "./chunk-JEZJ6HTF.js";
22
+ import {
23
+ coalesceDeltas,
24
+ createBufferedTurnTap,
25
+ replayTurnEvents,
26
+ stampReplaySeq
27
+ } from "./chunk-ES3I3UGP.js";
28
+
29
+ // src/chat-routes/draft-persistence.ts
30
+ var CONTENT_EVENT_TYPES = /* @__PURE__ */ new Set([
31
+ "text",
32
+ "reasoning",
33
+ "tool_call",
34
+ "tool_result",
35
+ "usage",
36
+ "notice",
37
+ "error",
38
+ "file",
39
+ "interaction",
40
+ "interaction.cancel",
41
+ "plan.submitted",
42
+ "message.part.updated"
43
+ ]);
44
+ var DEFAULT_INTERVAL_MS = 2e3;
45
+ var DEFAULT_BACKOFF_BYTES = 262144;
46
+ var DEFAULT_MAX_DRAFT_TOOL_OUTPUT_BYTES = 32768;
47
+ function isDraftContentEvent(event) {
48
+ return typeof event?.type === "string" && CONTENT_EVENT_TYPES.has(event.type);
49
+ }
50
+ function capDraftToolOutput(part, maxBytes) {
51
+ if (maxBytes <= 0) return part;
52
+ if (String(part.type ?? "") !== "tool") return part;
53
+ const state = part.state;
54
+ if (!state || typeof state !== "object") return part;
55
+ const record = state;
56
+ const output = record.output;
57
+ if (output === void 0 || output === null) return part;
58
+ const serialized = typeof output === "string" ? output : safeStringify(output);
59
+ if (serialized.length <= maxBytes) return part;
60
+ const metadata = record.metadata && typeof record.metadata === "object" ? record.metadata : {};
61
+ return {
62
+ ...part,
63
+ state: {
64
+ ...record,
65
+ output: `${serialized.slice(0, maxBytes)}\u2026[draft-truncated ${serialized.length - maxBytes} chars]`,
66
+ metadata: { ...metadata, draftTruncated: true }
67
+ }
68
+ };
69
+ }
70
+ function safeStringify(value) {
71
+ try {
72
+ return JSON.stringify(value) ?? "";
73
+ } catch {
74
+ return String(value);
75
+ }
76
+ }
77
+ function createAssistantDraftWriter(options) {
78
+ const log = options.log ?? (() => {
79
+ });
80
+ const baseIntervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
81
+ const backoffBytes = options.backoffBytes ?? DEFAULT_BACKOFF_BYTES;
82
+ const maxToolOutput = options.maxDraftToolOutputBytes ?? DEFAULT_MAX_DRAFT_TOOL_OUTPUT_BYTES;
83
+ let dirty = false;
84
+ let closed = false;
85
+ let inFlight;
86
+ let lastWriteAt = 0;
87
+ let lastBlobBytes = 0;
88
+ let rowId;
89
+ let writes = 0;
90
+ function currentIntervalMs() {
91
+ if (lastBlobBytes >= backoffBytes * 10) return baseIntervalMs * 5;
92
+ if (lastBlobBytes >= backoffBytes) return Math.round(baseIntervalMs * 2.5);
93
+ return baseIntervalMs;
94
+ }
95
+ async function projectValues(snapshot) {
96
+ const transform = options.transformText;
97
+ const content = transform ? await transform(snapshot.content) : snapshot.content;
98
+ let parts;
99
+ if (snapshot.parts) {
100
+ const capped = snapshot.parts.map((part) => capDraftToolOutput(part, maxToolOutput));
101
+ const redacted = transform ? await Promise.all(
102
+ capped.map(
103
+ async (part) => String(part.type ?? "") === "text" ? { ...part, text: await transform(String(part.text ?? "")) } : part
104
+ )
105
+ ) : capped;
106
+ parts = toChatMessageParts(redacted);
107
+ }
108
+ const usage = snapshot.usage ?? {};
109
+ return {
110
+ content,
111
+ ...parts && parts.length > 0 ? { parts } : {},
112
+ ...snapshot.model ? { model: snapshot.model } : {},
113
+ ...usage.inputTokens !== void 0 ? { inputTokens: usage.inputTokens } : {},
114
+ ...usage.outputTokens !== void 0 ? { outputTokens: usage.outputTokens } : {},
115
+ ...usage.reasoningTokens !== void 0 ? { reasoningTokens: usage.reasoningTokens } : {},
116
+ ...usage.cacheReadTokens !== void 0 ? { cacheReadTokens: usage.cacheReadTokens } : {},
117
+ ...usage.cacheWriteTokens !== void 0 ? { cacheWriteTokens: usage.cacheWriteTokens } : {},
118
+ ...usage.costUsd !== void 0 ? { costUsd: usage.costUsd } : {}
119
+ };
120
+ }
121
+ async function adoptRow() {
122
+ if (rowId) return rowId;
123
+ const existing = (await options.store.listMessages(options.threadId)).find(
124
+ (message) => message.id === options.messageId
125
+ );
126
+ if (existing) rowId = existing.id;
127
+ return rowId;
128
+ }
129
+ async function writeOnce(values) {
130
+ if (await adoptRow()) {
131
+ await options.store.updateMessage(rowId, values);
132
+ writes += 1;
133
+ return;
134
+ }
135
+ const inserted = await options.store.appendMessage({
136
+ id: options.messageId,
137
+ threadId: options.threadId,
138
+ role: "assistant",
139
+ ...values
140
+ });
141
+ rowId = rowIdOf(inserted) ?? options.messageId;
142
+ writes += 1;
143
+ }
144
+ function trigger() {
145
+ if (closed) return;
146
+ if (!dirty) return;
147
+ if (inFlight) return;
148
+ const now = Date.now();
149
+ if (lastWriteAt !== 0 && now - lastWriteAt < currentIntervalMs()) return;
150
+ const snapshot = options.snapshot();
151
+ if (!snapshot) return;
152
+ if (!snapshot.content && (!snapshot.parts || snapshot.parts.length === 0)) return;
153
+ dirty = false;
154
+ lastWriteAt = now;
155
+ inFlight = (async () => {
156
+ try {
157
+ const values = await projectValues(snapshot);
158
+ lastBlobBytes = values.parts ? safeStringify(values.parts).length : 0;
159
+ await writeOnce(values);
160
+ } catch (err) {
161
+ log("[chat-routes] incremental assistant persistence failed", {
162
+ messageId: options.messageId,
163
+ error: err instanceof Error ? err.message : String(err)
164
+ });
165
+ } finally {
166
+ inFlight = void 0;
167
+ if (dirty && !closed) trigger();
168
+ }
169
+ })();
170
+ }
171
+ return {
172
+ notify(event) {
173
+ if (closed) return;
174
+ if (isDraftContentEvent(event)) dirty = true;
175
+ trigger();
176
+ },
177
+ async close() {
178
+ closed = true;
179
+ if (inFlight) await inFlight;
180
+ },
181
+ async finalize(values) {
182
+ closed = true;
183
+ if (inFlight) await inFlight;
184
+ await writeOnce(values);
185
+ },
186
+ rowId: () => rowId,
187
+ async discard() {
188
+ closed = true;
189
+ if (inFlight) await inFlight;
190
+ if (!options.store.deleteMessage) return;
191
+ try {
192
+ if (!await adoptRow()) return;
193
+ await options.store.deleteMessage(rowId);
194
+ rowId = void 0;
195
+ } catch (err) {
196
+ log("[chat-routes] draft assistant row discard failed", {
197
+ messageId: options.messageId,
198
+ error: err instanceof Error ? err.message : String(err)
199
+ });
200
+ }
201
+ },
202
+ writeCount: () => writes
203
+ };
204
+ }
205
+ function storeSupportsDraftPersistence(store) {
206
+ return typeof store.updateMessage === "function";
207
+ }
208
+ function rowIdOf(inserted) {
209
+ const id = inserted?.id;
210
+ return typeof id === "string" && id ? id : null;
211
+ }
212
+ function assistantRowIdForTurn(turnKey) {
213
+ return `assistant:${turnKey}`;
214
+ }
215
+
216
+ // src/chat-routes/turn-routes.ts
217
+ import {
218
+ deriveExecutionId,
219
+ handleChatTurn
220
+ } from "@tangle-network/agent-runtime/durable";
221
+ function failureReasonOf(data) {
222
+ if (!data) return void 0;
223
+ const message = data.message ?? data.error ?? data.reason;
224
+ if (typeof message === "string" && message.length > 0) return message;
225
+ return void 0;
226
+ }
227
+ function errorResponse(err) {
228
+ return Response.json({ code: err.code, error: err.message }, { status: err.status });
229
+ }
230
+ function validateTurnBody(body, maxInlinePartBytes) {
231
+ const threadId = typeof body.threadId === "string" ? body.threadId.trim() : "";
232
+ if (!threadId) throw new ChatTurnInputError("Missing threadId");
233
+ const rawContent = body.content ?? body.message ?? "";
234
+ if (typeof rawContent !== "string") throw new ChatTurnInputError("content must be a string");
235
+ const content = rawContent.trim();
236
+ const fileParts = parseChatTurnParts(body.parts);
237
+ const mentions = parseFileMentions(body.mentions);
238
+ const hasAttachments = Array.isArray(body.attachments) && body.attachments.length > 0;
239
+ if (!content && fileParts.length === 0 && mentions.length === 0 && !hasAttachments) {
240
+ throw new ChatTurnInputError("Missing content (send text, parts, mentions, attachments, or any combination)");
241
+ }
242
+ assertPromptPartsWithinCap(fileParts, maxInlinePartBytes);
243
+ let turnId;
244
+ try {
245
+ turnId = normalizeClientTurnId(body.turnId);
246
+ } catch (err) {
247
+ throw new ChatTurnInputError(err instanceof Error ? err.message : "Invalid turnId");
248
+ }
249
+ return {
250
+ // The VALIDATED, deduped mention list replaces the raw one on the payload,
251
+ // so every downstream seam (`authorize`, `contextGate`, `beforeTurn`,
252
+ // `produce`) reads checked paths and never the request's own.
253
+ payload: { ...body, threadId, content, mentions },
254
+ content,
255
+ fileParts,
256
+ mentions,
257
+ turnId
258
+ };
259
+ }
260
+ function userPartsWithFiles(userParts, fileParts, mentions) {
261
+ return toChatMessageParts([
262
+ ...userParts,
263
+ ...fileParts.map((part) => ({ ...part })),
264
+ ...mentions.map((mention) => ({ ...mentionInputToPart(mention) }))
265
+ ]);
266
+ }
267
+ async function* tapRawEvents(source, onRawEvent, log) {
268
+ for await (const event of source) {
269
+ try {
270
+ await onRawEvent(event);
271
+ } catch (err) {
272
+ log("[chat-routes] onRawEvent failed", { error: err instanceof Error ? err.message : String(err) });
273
+ }
274
+ yield event;
275
+ }
276
+ }
277
+ async function* withStreamHeartbeat(source, intervalMs, makeEvent) {
278
+ const iterator = source[Symbol.asyncIterator]();
279
+ try {
280
+ let pending = iterator.next();
281
+ let windowStart = Date.now();
282
+ let tick = 0;
283
+ for (; ; ) {
284
+ let timer;
285
+ let winner;
286
+ try {
287
+ const heartbeat = new Promise((resolve) => {
288
+ timer = setTimeout(() => resolve("heartbeat"), intervalMs);
289
+ });
290
+ winner = await Promise.race([pending.then(() => "event"), heartbeat]);
291
+ } finally {
292
+ if (timer !== void 0) clearTimeout(timer);
293
+ }
294
+ if (winner === "heartbeat") {
295
+ tick += 1;
296
+ yield makeEvent({ elapsedMs: Date.now() - windowStart, tick });
297
+ continue;
298
+ }
299
+ const result = await pending;
300
+ if (result.done) return;
301
+ yield result.value;
302
+ pending = iterator.next();
303
+ windowStart = Date.now();
304
+ tick = 0;
305
+ }
306
+ } finally {
307
+ await iterator.return?.();
308
+ }
309
+ }
310
+ function createChatTurnRoutes(options) {
311
+ const log = options.log ?? ((message, meta) => console.error(message, meta ?? ""));
312
+ const draftStore = options.store;
313
+ if (options.incrementalPersistence && !storeSupportsDraftPersistence(draftStore)) {
314
+ throw new Error(
315
+ "incrementalPersistence requires a store with updateMessage() \u2014 `/chat-store`'s createChatStore has it; a product store must implement it or omit the option"
316
+ );
317
+ }
318
+ const draftTuning = options.incrementalPersistence === false || !storeSupportsDraftPersistence(draftStore) ? null : options.incrementalPersistence ?? {};
319
+ async function turn(request, ctx) {
320
+ const [rawBody, badBody] = await parseJsonObjectBody(request);
321
+ if (badBody) return badBody;
322
+ let parsed;
323
+ try {
324
+ parsed = validateTurnBody(rawBody, options.maxInlinePartBytes);
325
+ } catch (err) {
326
+ if (err instanceof ChatTurnInputError) return errorResponse(err);
327
+ throw err;
328
+ }
329
+ const { payload, content, fileParts, mentions, turnId } = parsed;
330
+ const auth = await options.authorize({ request, intent: "turn", body: payload });
331
+ if (!auth.ok) return auth.response;
332
+ const { tenantId, userId, context } = auth;
333
+ const existingMessages = (await options.store.listMessages(payload.threadId)).map((m) => ({
334
+ id: m.id,
335
+ role: m.role,
336
+ content: m.content,
337
+ parts: m.parts ?? null
338
+ }));
339
+ let hasRunningTurn = false;
340
+ if (draftTuning && existingMessages.at(-1)?.role === "assistant") {
341
+ try {
342
+ hasRunningTurn = (await options.turnStore.listRunning?.(payload.threadId) ?? []).length > 0;
343
+ } catch (err) {
344
+ log("[chat-routes] listRunning probe failed; treating the thread as idle", {
345
+ threadId: payload.threadId,
346
+ error: err instanceof Error ? err.message : String(err)
347
+ });
348
+ }
349
+ }
350
+ const chatTurn = resolveChatTurn({ existingMessages, userContent: content, turnId, hasRunningTurn });
351
+ const identity = {
352
+ tenantId,
353
+ sessionId: payload.threadId,
354
+ userId,
355
+ turnIndex: chatTurn.turnIndex
356
+ };
357
+ const executionId = deriveExecutionId({
358
+ projectId: options.projectId,
359
+ sessionId: payload.threadId,
360
+ turnIndex: chatTurn.turnIndex
361
+ });
362
+ const turnStreamId = crypto.randomUUID();
363
+ const prompt = fileParts.length === 0 ? content : content ? [{ type: "text", text: content }, ...fileParts] : [...fileParts];
364
+ let produceArgs = {
365
+ request,
366
+ body: payload,
367
+ identity,
368
+ context,
369
+ prompt,
370
+ executionId,
371
+ turnStreamId,
372
+ priorMessages: chatTurn.priorMessages,
373
+ ...ctx?.executionLimits ? { executionLimits: ctx.executionLimits } : {}
374
+ };
375
+ let lockAcquired = false;
376
+ let lockHandle;
377
+ let lockReleased = false;
378
+ const releaseLock = async () => {
379
+ if (!lockAcquired || lockReleased) return;
380
+ lockReleased = true;
381
+ try {
382
+ await options.turnLock.release(lockHandle);
383
+ } catch (err) {
384
+ log("[chat-routes] turnLock.release failed", {
385
+ turnId: turnStreamId,
386
+ error: err instanceof Error ? err.message : String(err)
387
+ });
388
+ }
389
+ };
390
+ if (options.turnLock) {
391
+ const acquired = await options.turnLock.acquire(produceArgs);
392
+ if (!acquired.acquired) return acquired.response;
393
+ lockAcquired = true;
394
+ lockHandle = acquired.handle;
395
+ }
396
+ let producer;
397
+ let draft;
398
+ let assistantMessageId;
399
+ const assistantRowId = () => assistantMessageId ?? draft?.rowId() ?? null;
400
+ let runFailed = false;
401
+ let lastFailureData;
402
+ let turnStartedAtMs = 0;
403
+ let turnStarted = false;
404
+ let lifecycleSettled = false;
405
+ let gatedTurn = false;
406
+ const fireTerminalLifecycle = async (failed, terminalError) => {
407
+ if (lifecycleSettled) return;
408
+ lifecycleSettled = true;
409
+ const lifecycle = options.lifecycle;
410
+ if (!lifecycle) return;
411
+ const durationMs = Date.now() - turnStartedAtMs;
412
+ try {
413
+ if (failed) {
414
+ await lifecycle.onTurnError?.({
415
+ identity,
416
+ executionId,
417
+ turnStreamId,
418
+ context,
419
+ durationMs,
420
+ error: terminalError ?? lastFailureData ?? new Error("chat turn failed")
421
+ });
422
+ } else {
423
+ const failoverInfo = producer?.modelFailover?.();
424
+ const attribution = producer?.modelAttribution?.();
425
+ await lifecycle.onTurnComplete?.({
426
+ identity,
427
+ executionId,
428
+ turnStreamId,
429
+ context,
430
+ durationMs,
431
+ finalText: producer?.finalText() ?? "",
432
+ usage: producer?.usage?.() ?? {},
433
+ assistantMessageId: assistantRowId(),
434
+ ...gatedTurn ? { gated: true } : {},
435
+ ...producer?.model ? { model: producer.model } : {},
436
+ ...attribution?.requestedModel ? { requestedModel: attribution.requestedModel } : {},
437
+ ...attribution?.servedModel ? { servedModel: attribution.servedModel } : {},
438
+ ...attribution?.servedProvider ? { servedProvider: attribution.servedProvider } : {},
439
+ ...attribution?.servedSource ? { servedSource: attribution.servedSource } : {},
440
+ ...failoverInfo ? { modelFailover: failoverInfo } : {}
441
+ });
442
+ }
443
+ } catch (err) {
444
+ log("[chat-routes] lifecycle terminal hook failed", {
445
+ turnId: turnStreamId,
446
+ error: err instanceof Error ? err.message : String(err)
447
+ });
448
+ }
449
+ };
450
+ try {
451
+ const insertUserMessage = chatTurn.shouldInsertUserMessage && (auth.insertUserMessage ?? true);
452
+ let userMessageId = chatTurn.reusedUserMessageId ?? null;
453
+ if (insertUserMessage) {
454
+ userMessageId = rowIdOf(
455
+ await options.store.appendMessage({
456
+ threadId: payload.threadId,
457
+ role: "user",
458
+ content,
459
+ parts: userPartsWithFiles(chatTurn.userParts, fileParts, mentions)
460
+ })
461
+ );
462
+ }
463
+ produceArgs = { ...produceArgs, userMessageId };
464
+ if (options.contextGate) {
465
+ const gate = await options.contextGate(produceArgs);
466
+ if (gate.proceed === "handoff") {
467
+ await releaseLock();
468
+ return gate.response;
469
+ }
470
+ if (!gate.proceed) {
471
+ await releaseLock();
472
+ gatedTurn = true;
473
+ turnStartedAtMs = Date.now();
474
+ if (options.lifecycle?.onTurnStart) {
475
+ try {
476
+ await options.lifecycle.onTurnStart({
477
+ identity,
478
+ executionId,
479
+ turnStreamId,
480
+ context,
481
+ startedAt: turnStartedAtMs
482
+ });
483
+ } catch (err) {
484
+ log("[chat-routes] lifecycle.onTurnStart failed", {
485
+ turnId: turnStreamId,
486
+ error: err instanceof Error ? err.message : String(err)
487
+ });
488
+ }
489
+ }
490
+ await fireTerminalLifecycle(false, void 0);
491
+ return gate.response;
492
+ }
493
+ }
494
+ if (options.beforeTurn) {
495
+ const patch = await options.beforeTurn(produceArgs);
496
+ if (patch) produceArgs = { ...produceArgs, ...patch };
497
+ }
498
+ const tap = createBufferedTurnTap({
499
+ store: options.turnStore,
500
+ turnId: turnStreamId,
501
+ scopeId: payload.threadId,
502
+ coalesce: options.coalesceTurnEvents ?? coalesceDeltas
503
+ });
504
+ const turnMarker = { type: "turn", turnId: turnStreamId };
505
+ await tap.onEvent(turnMarker);
506
+ if (draftTuning) {
507
+ draft = createAssistantDraftWriter({
508
+ ...draftTuning,
509
+ store: draftStore,
510
+ threadId: payload.threadId,
511
+ messageId: options.draftMessageId ? options.draftMessageId({ identity, executionId, threadId: payload.threadId }) : assistantRowIdForTurn(executionId),
512
+ snapshot: () => {
513
+ if (!producer) return null;
514
+ return {
515
+ content: producer.finalText(),
516
+ ...producer.draftParts ? { parts: producer.draftParts() } : {},
517
+ ...producer.usage ? { usage: producer.usage() } : {},
518
+ ...producer.model ? { model: producer.model } : {}
519
+ };
520
+ },
521
+ ...options.transformFinalText ? { transformText: options.transformFinalText } : {},
522
+ log
523
+ });
524
+ }
525
+ turnStartedAtMs = Date.now();
526
+ turnStarted = true;
527
+ if (options.lifecycle?.onTurnStart) {
528
+ try {
529
+ await options.lifecycle.onTurnStart({
530
+ identity,
531
+ executionId,
532
+ turnStreamId,
533
+ context,
534
+ startedAt: turnStartedAtMs
535
+ });
536
+ } catch (err) {
537
+ log("[chat-routes] lifecycle.onTurnStart failed", {
538
+ turnId: turnStreamId,
539
+ error: err instanceof Error ? err.message : String(err)
540
+ });
541
+ }
542
+ }
543
+ const result = handleChatTurn({
544
+ identity,
545
+ waitUntil: ctx?.waitUntil,
546
+ log,
547
+ hooks: {
548
+ // The engine wants a synchronous producer; box resolution is async —
549
+ // defer it into the generator's first pull.
550
+ produce: () => ({
551
+ stream: (async function* () {
552
+ producer = await options.produce(produceArgs);
553
+ let source = producer.stream;
554
+ if (ctx?.cancelOnDisconnect) {
555
+ source = abortOnSignal(source, request.signal);
556
+ }
557
+ if (options.onRawEvent) {
558
+ source = tapRawEvents(source, (event) => options.onRawEvent(event, context), log);
559
+ }
560
+ if (options.heartbeat) {
561
+ source = withStreamHeartbeat(source, options.heartbeat.intervalMs, options.heartbeat.event);
562
+ }
563
+ for await (const event of source) yield event;
564
+ })(),
565
+ finalText: () => producer?.finalText() ?? ""
566
+ }),
567
+ onEvent: async (event) => {
568
+ if (event.type === "session.run.failed" || event.type === "error") {
569
+ runFailed = true;
570
+ lastFailureData = event.data;
571
+ }
572
+ await tap.onEvent(event);
573
+ draft?.notify(event);
574
+ if (options.onEvent) await options.onEvent(event, context);
575
+ },
576
+ ...options.transformFinalText ? { transformFinalText: options.transformFinalText } : {},
577
+ persistAssistantMessage: async ({ finalText }) => {
578
+ if (ctx?.cancelOnDisconnect && request.signal.aborted) {
579
+ await draft?.discard();
580
+ assistantMessageId = null;
581
+ return;
582
+ }
583
+ const rawParts = producer?.assistantParts ? producer.assistantParts() : void 0;
584
+ const projected = rawParts && options.transformFinalText ? await Promise.all(
585
+ rawParts.map(
586
+ async (part) => String(part.type ?? "") === "text" ? { ...part, text: await options.transformFinalText(String(part.text ?? "")) } : part
587
+ )
588
+ ) : rawParts;
589
+ const parts = projected ? toChatMessageParts(projected) : void 0;
590
+ if (!finalText.trim() && (!parts || parts.length === 0)) {
591
+ await draft?.discard();
592
+ assistantMessageId = draft?.rowId() ?? null;
593
+ return;
594
+ }
595
+ const usage = producer?.usage?.() ?? {};
596
+ const attribution = producer?.modelAttribution?.();
597
+ const values = {
598
+ content: finalText,
599
+ ...parts && parts.length > 0 ? { parts } : {},
600
+ ...producer?.model ? { model: producer.model } : {},
601
+ ...attribution?.requestedModel ? { requestedModel: attribution.requestedModel } : {},
602
+ ...attribution?.servedModel ? { servedModel: attribution.servedModel } : {},
603
+ ...attribution?.servedProvider ? { servedProvider: attribution.servedProvider } : {},
604
+ ...attribution?.servedSource ? { servedSource: attribution.servedSource } : {},
605
+ ...usage.inputTokens !== void 0 ? { inputTokens: usage.inputTokens } : {},
606
+ ...usage.outputTokens !== void 0 ? { outputTokens: usage.outputTokens } : {},
607
+ ...usage.reasoningTokens !== void 0 ? { reasoningTokens: usage.reasoningTokens } : {},
608
+ ...usage.cacheReadTokens !== void 0 ? { cacheReadTokens: usage.cacheReadTokens } : {},
609
+ ...usage.cacheWriteTokens !== void 0 ? { cacheWriteTokens: usage.cacheWriteTokens } : {},
610
+ ...usage.costUsd !== void 0 ? { costUsd: usage.costUsd } : {}
611
+ };
612
+ if (draft) {
613
+ await draft.finalize(values);
614
+ assistantMessageId = draft.rowId() ?? null;
615
+ return;
616
+ }
617
+ assistantMessageId = rowIdOf(
618
+ await options.store.appendMessage({
619
+ threadId: payload.threadId,
620
+ role: "assistant",
621
+ ...values
622
+ })
623
+ );
624
+ },
625
+ ...options.onTurnComplete ? {
626
+ // Wired into the engine's completion hook, which fires only when
627
+ // the stream ended without throwing. A terminal error EVENT
628
+ // (not a throw) still lands here — so surface `runFailed` so the
629
+ // product skips billing an errored turn instead of marking it
630
+ // complete with empty text.
631
+ onTurnComplete: ({ identity: turnIdentity, finalText }) => {
632
+ const failoverInfo = producer?.modelFailover?.();
633
+ const attribution = producer?.modelAttribution?.();
634
+ return options.onTurnComplete({
635
+ identity: turnIdentity,
636
+ finalText,
637
+ context,
638
+ failed: runFailed,
639
+ assistantMessageId: assistantRowId(),
640
+ ...runFailed ? { failureReason: failureReasonOf(lastFailureData) } : {},
641
+ ...producer?.model ? { model: producer.model } : {},
642
+ ...attribution?.requestedModel ? { requestedModel: attribution.requestedModel } : {},
643
+ ...attribution?.servedModel ? { servedModel: attribution.servedModel } : {},
644
+ ...attribution?.servedProvider ? { servedProvider: attribution.servedProvider } : {},
645
+ ...attribution?.servedSource ? { servedSource: attribution.servedSource } : {},
646
+ ...failoverInfo ? { modelFailover: failoverInfo } : {}
647
+ });
648
+ }
649
+ } : {},
650
+ ...options.traceFlush ? { traceFlush: () => options.traceFlush(context) } : {}
651
+ }
652
+ });
653
+ const [clientBody, drainBody] = result.body.tee();
654
+ const drained = (async () => {
655
+ const reader = drainBody.getReader();
656
+ let drainError;
657
+ try {
658
+ for (; ; ) {
659
+ const { done } = await reader.read();
660
+ if (done) break;
661
+ }
662
+ } catch (err) {
663
+ drainError = err;
664
+ log("[chat-routes] turn drain failed", {
665
+ turnId: turnStreamId,
666
+ error: err instanceof Error ? err.message : String(err)
667
+ });
668
+ }
669
+ const failed = runFailed || drainError !== void 0;
670
+ if (ctx?.cancelOnDisconnect && request.signal.aborted) {
671
+ await draft?.discard();
672
+ } else {
673
+ await draft?.close();
674
+ }
675
+ try {
676
+ await tap.done(failed ? "error" : "complete");
677
+ } catch (err) {
678
+ log("[chat-routes] turn buffer finalize failed", {
679
+ turnId: turnStreamId,
680
+ error: err instanceof Error ? err.message : String(err)
681
+ });
682
+ }
683
+ await fireTerminalLifecycle(failed, drainError);
684
+ await releaseLock();
685
+ })();
686
+ if (ctx?.waitUntil) ctx.waitUntil(drained);
687
+ else void drained.catch(() => {
688
+ });
689
+ const encoder = new TextEncoder();
690
+ const marker = new ReadableStream({
691
+ start(controller) {
692
+ controller.enqueue(encoder.encode(`${JSON.stringify(turnMarker)}
693
+ `));
694
+ controller.close();
695
+ }
696
+ });
697
+ const body = concatStreams([marker, clientBody]);
698
+ return new Response(body, {
699
+ headers: {
700
+ "Content-Type": result.contentType,
701
+ "Cache-Control": "no-cache",
702
+ "X-Turn-Id": turnStreamId
703
+ }
704
+ });
705
+ } catch (err) {
706
+ if (turnStarted) await fireTerminalLifecycle(true, err);
707
+ await releaseLock();
708
+ throw err;
709
+ }
710
+ }
711
+ async function replay(request, params) {
712
+ const turnId = params.turnId?.trim();
713
+ if (!turnId) return Response.json({ error: "Missing turnId" }, { status: 400 });
714
+ const auth = await options.authorize({ request, intent: "replay", turnId });
715
+ if (!auth.ok) return auth.response;
716
+ const fromSeqRaw = new URL(request.url).searchParams.get("fromSeq");
717
+ const fromSeq = fromSeqRaw ? Math.max(0, Math.trunc(Number(fromSeqRaw)) || 0) : 0;
718
+ const encoder = new TextEncoder();
719
+ const events = replayTurnEvents({
720
+ store: options.turnStore,
721
+ turnId,
722
+ fromSeq,
723
+ ...options.replay?.pollMs !== void 0 ? { pollMs: options.replay.pollMs } : {},
724
+ ...options.replay?.timeoutMs !== void 0 ? { timeoutMs: options.replay.timeoutMs } : {}
725
+ });
726
+ const body = new ReadableStream({
727
+ async pull(controller) {
728
+ const { done, value } = await events.next();
729
+ if (done) {
730
+ controller.close();
731
+ return;
732
+ }
733
+ controller.enqueue(encoder.encode(`${stampReplaySeq(value)}
734
+ `));
735
+ },
736
+ cancel() {
737
+ void events.return(void 0);
738
+ }
739
+ });
740
+ return new Response(body, {
741
+ headers: {
742
+ "Content-Type": "application/x-ndjson",
743
+ "Cache-Control": "no-cache"
744
+ }
745
+ });
746
+ }
747
+ async function running(request) {
748
+ const threadId = new URL(request.url).searchParams.get("threadId")?.trim();
749
+ if (!threadId) return Response.json({ error: "Missing threadId" }, { status: 400 });
750
+ const auth = await options.authorize({ request, intent: "running", threadId });
751
+ if (!auth.ok) return auth.response;
752
+ const ids = await options.turnStore.listRunning?.(threadId) ?? [];
753
+ return Response.json({ running: ids });
754
+ }
755
+ return {
756
+ turn,
757
+ replay,
758
+ running,
759
+ interactions: options.interactions ? createInteractionAnswerRoute(options.interactions) : null
760
+ };
761
+ }
762
+ function concatStreams(streams) {
763
+ let index = 0;
764
+ let reader = null;
765
+ return new ReadableStream({
766
+ async pull(controller) {
767
+ for (; ; ) {
768
+ if (!reader) {
769
+ const next = streams[index++];
770
+ if (!next) {
771
+ controller.close();
772
+ return;
773
+ }
774
+ reader = next.getReader();
775
+ }
776
+ const { done, value } = await reader.read();
777
+ if (done) {
778
+ reader = null;
779
+ continue;
780
+ }
781
+ controller.enqueue(value);
782
+ return;
783
+ }
784
+ },
785
+ async cancel(reason) {
786
+ await reader?.cancel(reason);
787
+ for (const stream of streams.slice(index)) await stream.cancel(reason);
788
+ }
789
+ });
790
+ }
791
+ async function* abortOnSignal(source, signal) {
792
+ const iterator = source[Symbol.asyncIterator]();
793
+ try {
794
+ for (; ; ) {
795
+ const next = await nextWithAbort(iterator, signal);
796
+ if (next.done) return;
797
+ yield next.value;
798
+ }
799
+ } finally {
800
+ await closeIteratorAfterAbort(iterator);
801
+ }
802
+ }
803
+ function nextWithAbort(iterator, signal) {
804
+ if (signal.aborted) return Promise.reject(new Error("chat turn cancelled"));
805
+ return new Promise((resolve, reject) => {
806
+ let settled = false;
807
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
808
+ const onAbort = () => {
809
+ if (settled) return;
810
+ settled = true;
811
+ cleanup();
812
+ reject(new Error("chat turn cancelled"));
813
+ };
814
+ signal.addEventListener("abort", onAbort, { once: true });
815
+ if (signal.aborted) {
816
+ onAbort();
817
+ return;
818
+ }
819
+ Promise.resolve().then(() => iterator.next()).then(
820
+ (result) => {
821
+ if (settled) return;
822
+ settled = true;
823
+ cleanup();
824
+ resolve(result);
825
+ },
826
+ (error) => {
827
+ if (settled) return;
828
+ settled = true;
829
+ cleanup();
830
+ reject(error);
831
+ }
832
+ );
833
+ });
834
+ }
835
+ var ABORT_CLEANUP_TIMEOUT_MS = 50;
836
+ async function closeIteratorAfterAbort(iterator) {
837
+ let closing;
838
+ try {
839
+ const result = iterator.return?.();
840
+ if (result) closing = Promise.resolve(result);
841
+ } catch {
842
+ return;
843
+ }
844
+ if (!closing) return;
845
+ let timeout;
846
+ try {
847
+ await Promise.race([
848
+ Promise.resolve(closing).catch(() => void 0),
849
+ new Promise((resolve) => {
850
+ timeout = setTimeout(resolve, ABORT_CLEANUP_TIMEOUT_MS);
851
+ })
852
+ ]);
853
+ } finally {
854
+ if (timeout !== void 0) clearTimeout(timeout);
855
+ }
856
+ }
857
+
858
+ // src/chat-routes/gateway-adapter.ts
859
+ async function* streamChatRouteAsSandboxEvents(options) {
860
+ const parentSignal = options.signal ?? options.request.signal;
861
+ if (parentSignal.aborted) return;
862
+ let reader;
863
+ const stopForwarding = () => void reader?.cancel();
864
+ parentSignal.addEventListener("abort", stopForwarding, { once: true });
865
+ const request = new Request(options.request.url, {
866
+ ...chatTurnRequestInit(options.payload),
867
+ headers: options.request.headers
868
+ });
869
+ request.headers.set("Content-Type", "application/json");
870
+ let complete = false;
871
+ try {
872
+ const response = await options.routes.turn(
873
+ request,
874
+ {
875
+ ...options.waitUntil ? { waitUntil: options.waitUntil } : {},
876
+ ...options.executionLimits ? { executionLimits: options.executionLimits } : {}
877
+ }
878
+ );
879
+ if (!response.ok) {
880
+ await response.body?.cancel().catch(() => void 0);
881
+ throw new Error(`chat route rejected the gateway turn (${response.status})`);
882
+ }
883
+ if (!response.body) throw new Error("chat route returned no stream");
884
+ reader = response.body.getReader();
885
+ if (parentSignal.aborted) {
886
+ await reader.cancel().catch(() => void 0);
887
+ return;
888
+ }
889
+ const decoder = new TextDecoder();
890
+ let buffer = "";
891
+ for (; ; ) {
892
+ const chunk = await reader.read();
893
+ if (chunk.done) {
894
+ complete = true;
895
+ buffer += decoder.decode();
896
+ const event = toSandboxEvent(parseEvent(buffer));
897
+ if (event) yield event;
898
+ break;
899
+ }
900
+ buffer += decoder.decode(chunk.value, { stream: true });
901
+ const lines = buffer.split("\n");
902
+ buffer = lines.pop() ?? "";
903
+ for (const line of lines) {
904
+ const event = toSandboxEvent(parseEvent(line));
905
+ if (event) yield event;
906
+ }
907
+ }
908
+ } finally {
909
+ if (!complete) await reader?.cancel().catch(() => void 0);
910
+ reader?.releaseLock();
911
+ parentSignal.removeEventListener("abort", stopForwarding);
912
+ }
913
+ }
914
+ function parseEvent(line) {
915
+ if (!line.trim()) return null;
916
+ let value;
917
+ try {
918
+ value = JSON.parse(line);
919
+ } catch {
920
+ throw new Error("chat route returned invalid NDJSON");
921
+ }
922
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
923
+ throw new Error("chat route returned a non-object event");
924
+ }
925
+ const event = value;
926
+ if (event.type !== void 0 && typeof event.type !== "string") {
927
+ throw new Error("chat route returned an invalid event type");
928
+ }
929
+ const data = event.data;
930
+ if (data !== void 0 && (!data || typeof data !== "object" || Array.isArray(data))) {
931
+ throw new Error("chat route returned invalid event data");
932
+ }
933
+ const dataUsage = data && data.usage;
934
+ if (dataUsage !== void 0 && (!dataUsage || typeof dataUsage !== "object" || Array.isArray(dataUsage))) {
935
+ throw new Error("chat route returned invalid data.usage");
936
+ }
937
+ if (event.text !== void 0 && typeof event.text !== "string") {
938
+ throw new Error("chat route returned an invalid text event");
939
+ }
940
+ const usage = event.usage;
941
+ if (usage !== void 0 && (!usage || typeof usage !== "object" || Array.isArray(usage))) {
942
+ throw new Error("chat route returned invalid usage");
943
+ }
944
+ if (event.toolName !== void 0 && typeof event.toolName !== "string") {
945
+ throw new Error("chat route returned an invalid tool name");
946
+ }
947
+ return {
948
+ ...typeof event.type === "string" ? { type: event.type } : {},
949
+ ...data ? { data } : {},
950
+ ...typeof event.text === "string" ? { text: event.text } : {},
951
+ ...usage ? { usage } : {},
952
+ ...typeof event.toolName === "string" ? { toolName: event.toolName } : {}
953
+ };
954
+ }
955
+ function isNonNegativeSafeInteger(value) {
956
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
957
+ }
958
+ function aliasedSafeInteger(usage, field, alias) {
959
+ const value = usage[field];
960
+ const aliasValue = alias ? usage[alias] : void 0;
961
+ for (const candidate of [value, aliasValue]) {
962
+ if (candidate !== void 0 && !isNonNegativeSafeInteger(candidate)) {
963
+ throw new Error(`chat route returned invalid usage.${field}`);
964
+ }
965
+ }
966
+ if (value !== void 0 && aliasValue !== void 0 && value !== aliasValue) {
967
+ throw new Error(`chat route returned conflicting usage.${field}`);
968
+ }
969
+ return value ?? aliasValue;
970
+ }
971
+ function aliasedNonNegativeNumber(usage, field, alias) {
972
+ const value = usage[field];
973
+ const aliasValue = alias ? usage[alias] : void 0;
974
+ for (const candidate of [value, aliasValue]) {
975
+ if (candidate !== void 0 && (typeof candidate !== "number" || !Number.isFinite(candidate) || candidate < 0)) {
976
+ throw new Error(`chat route returned invalid usage.${field}`);
977
+ }
978
+ }
979
+ if (value !== void 0 && aliasValue !== void 0 && value !== aliasValue) {
980
+ throw new Error(`chat route returned conflicting usage.${field}`);
981
+ }
982
+ return value ?? aliasValue;
983
+ }
984
+ function providerUsageUpdate(value) {
985
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
986
+ const source = value;
987
+ const usage = {};
988
+ const inputTokens = aliasedSafeInteger(source, "inputTokens", "promptTokens");
989
+ const outputTokens = aliasedSafeInteger(source, "outputTokens", "completionTokens");
990
+ const reasoningTokens = aliasedSafeInteger(source, "reasoningTokens");
991
+ const toolTokens = aliasedSafeInteger(source, "toolTokens");
992
+ const toolCallCount = aliasedSafeInteger(source, "toolCallCount");
993
+ const providerCostUsd = aliasedNonNegativeNumber(source, "providerCostUsd", "costUsd");
994
+ if (inputTokens !== void 0) usage.inputTokens = inputTokens;
995
+ if (outputTokens !== void 0) usage.outputTokens = outputTokens;
996
+ if (reasoningTokens !== void 0) usage.reasoningTokens = reasoningTokens;
997
+ if (toolTokens !== void 0) usage.toolTokens = toolTokens;
998
+ if (toolCallCount !== void 0) usage.toolCallCount = toolCallCount;
999
+ if (providerCostUsd !== void 0) usage.providerCostUsd = providerCostUsd;
1000
+ if (source.budgetEnforced !== void 0) {
1001
+ if (typeof source.budgetEnforced !== "boolean") {
1002
+ throw new Error("chat route returned invalid usage.budgetEnforced");
1003
+ }
1004
+ usage.budgetEnforced = source.budgetEnforced;
1005
+ }
1006
+ return Object.keys(usage).length > 0 ? usage : null;
1007
+ }
1008
+ function toSandboxEvent(event) {
1009
+ if (!event) return null;
1010
+ if (event.type === "text") {
1011
+ if (event.text === void 0) throw new Error("chat route text event had no text");
1012
+ return {
1013
+ type: "message.part.updated",
1014
+ data: { part: { type: "text" }, delta: event.text }
1015
+ };
1016
+ }
1017
+ if (event.type === "reasoning") return null;
1018
+ if (event.type === "usage" || event.usage || event.data?.usage) {
1019
+ const usage = providerUsageUpdate(event.usage ?? event.data?.usage);
1020
+ if (!usage) return null;
1021
+ return { type: "usage", data: { usage } };
1022
+ }
1023
+ if (event.type === "tool_result") {
1024
+ if (!event.toolName) throw new Error("chat route tool result had no tool name");
1025
+ return { type: "tool_result", data: { tool: { name: event.toolName } } };
1026
+ }
1027
+ if (event.type === "input-required" && !event.data?.inputRequired) {
1028
+ const prompt = typeof event.data?.prompt === "string" ? event.data.prompt : void 0;
1029
+ return {
1030
+ type: "input-required",
1031
+ data: { inputRequired: prompt ? { prompt } : {} }
1032
+ };
1033
+ }
1034
+ return {
1035
+ ...event.type ? { type: event.type } : {},
1036
+ ...event.data ? { data: event.data } : {}
1037
+ };
1038
+ }
1039
+
1040
+ export {
1041
+ isDraftContentEvent,
1042
+ createAssistantDraftWriter,
1043
+ storeSupportsDraftPersistence,
1044
+ rowIdOf,
1045
+ assistantRowIdForTurn,
1046
+ createChatTurnRoutes,
1047
+ streamChatRouteAsSandboxEvents
1048
+ };
1049
+ //# sourceMappingURL=chunk-LYAYNWDS.js.map