@tangle-network/agent-app 0.43.61 → 0.43.62

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.
@@ -26,7 +26,7 @@ import {
26
26
  normalizeClientTurnId,
27
27
  replayTurnEvents,
28
28
  resolveChatTurn
29
- } from "../chunk-VCOD5AOR.js";
29
+ } from "../chunk-YN7QR7MJ.js";
30
30
  import {
31
31
  createInteractionAnswerRoute
32
32
  } from "../chunk-7YW6EBDX.js";
@@ -58,6 +58,7 @@ import {
58
58
  import {
59
59
  asRecord,
60
60
  asString,
61
+ draftAssistantParts,
61
62
  finalizeAssistantParts,
62
63
  finalizePendingInteractionParts,
63
64
  getPartKey,
@@ -65,7 +66,7 @@ import {
65
66
  normalizePersistedPart,
66
67
  normalizeToolEvent,
67
68
  terminalizeDanglingAssistantToolUpdates
68
- } from "../chunk-IG46XCZM.js";
69
+ } from "../chunk-BG52UKNN.js";
69
70
  import {
70
71
  cancelStatusFor,
71
72
  interactionPartKey,
@@ -94,6 +95,192 @@ import "../chunk-7CTIUCQ4.js";
94
95
 
95
96
  // src/chat-routes/turn-routes.ts
96
97
  import { deriveExecutionId, handleChatTurn } from "@tangle-network/agent-runtime";
98
+
99
+ // src/chat-routes/draft-persistence.ts
100
+ var CONTENT_EVENT_TYPES = /* @__PURE__ */ new Set([
101
+ "text",
102
+ "reasoning",
103
+ "tool_call",
104
+ "tool_result",
105
+ "usage",
106
+ "notice",
107
+ "error",
108
+ "file",
109
+ "interaction",
110
+ "interaction.cancel",
111
+ "plan.submitted",
112
+ "message.part.updated"
113
+ ]);
114
+ var DEFAULT_INTERVAL_MS = 2e3;
115
+ var DEFAULT_BACKOFF_BYTES = 262144;
116
+ var DEFAULT_MAX_DRAFT_TOOL_OUTPUT_BYTES = 32768;
117
+ function isDraftContentEvent(event) {
118
+ return typeof event?.type === "string" && CONTENT_EVENT_TYPES.has(event.type);
119
+ }
120
+ function capDraftToolOutput(part, maxBytes) {
121
+ if (maxBytes <= 0) return part;
122
+ if (String(part.type ?? "") !== "tool") return part;
123
+ const state = part.state;
124
+ if (!state || typeof state !== "object") return part;
125
+ const record = state;
126
+ const output = record.output;
127
+ if (output === void 0 || output === null) return part;
128
+ const serialized = typeof output === "string" ? output : safeStringify(output);
129
+ if (serialized.length <= maxBytes) return part;
130
+ const metadata = record.metadata && typeof record.metadata === "object" ? record.metadata : {};
131
+ return {
132
+ ...part,
133
+ state: {
134
+ ...record,
135
+ output: `${serialized.slice(0, maxBytes)}\u2026[draft-truncated ${serialized.length - maxBytes} chars]`,
136
+ metadata: { ...metadata, draftTruncated: true }
137
+ }
138
+ };
139
+ }
140
+ function safeStringify(value) {
141
+ try {
142
+ return JSON.stringify(value) ?? "";
143
+ } catch {
144
+ return String(value);
145
+ }
146
+ }
147
+ function createAssistantDraftWriter(options) {
148
+ const log = options.log ?? (() => {
149
+ });
150
+ const baseIntervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
151
+ const backoffBytes = options.backoffBytes ?? DEFAULT_BACKOFF_BYTES;
152
+ const maxToolOutput = options.maxDraftToolOutputBytes ?? DEFAULT_MAX_DRAFT_TOOL_OUTPUT_BYTES;
153
+ let dirty = false;
154
+ let closed = false;
155
+ let inFlight;
156
+ let lastWriteAt = 0;
157
+ let lastBlobBytes = 0;
158
+ let rowId;
159
+ let writes = 0;
160
+ function currentIntervalMs() {
161
+ if (lastBlobBytes >= backoffBytes * 10) return baseIntervalMs * 5;
162
+ if (lastBlobBytes >= backoffBytes) return Math.round(baseIntervalMs * 2.5);
163
+ return baseIntervalMs;
164
+ }
165
+ async function projectValues(snapshot) {
166
+ const transform = options.transformText;
167
+ const content = transform ? await transform(snapshot.content) : snapshot.content;
168
+ let parts;
169
+ if (snapshot.parts) {
170
+ const capped = snapshot.parts.map((part) => capDraftToolOutput(part, maxToolOutput));
171
+ const redacted = transform ? await Promise.all(
172
+ capped.map(
173
+ async (part) => String(part.type ?? "") === "text" ? { ...part, text: await transform(String(part.text ?? "")) } : part
174
+ )
175
+ ) : capped;
176
+ parts = toChatMessageParts(redacted);
177
+ }
178
+ const usage = snapshot.usage ?? {};
179
+ return {
180
+ content,
181
+ ...parts && parts.length > 0 ? { parts } : {},
182
+ ...snapshot.model ? { model: snapshot.model } : {},
183
+ ...usage.inputTokens !== void 0 ? { inputTokens: usage.inputTokens } : {},
184
+ ...usage.outputTokens !== void 0 ? { outputTokens: usage.outputTokens } : {},
185
+ ...usage.reasoningTokens !== void 0 ? { reasoningTokens: usage.reasoningTokens } : {},
186
+ ...usage.cacheReadTokens !== void 0 ? { cacheReadTokens: usage.cacheReadTokens } : {},
187
+ ...usage.cacheWriteTokens !== void 0 ? { cacheWriteTokens: usage.cacheWriteTokens } : {},
188
+ ...usage.costUsd !== void 0 ? { costUsd: usage.costUsd } : {}
189
+ };
190
+ }
191
+ async function adoptRow() {
192
+ if (rowId) return rowId;
193
+ const existing = (await options.store.listMessages(options.threadId)).find(
194
+ (message) => message.id === options.messageId
195
+ );
196
+ if (existing) rowId = existing.id;
197
+ return rowId;
198
+ }
199
+ async function writeOnce(values) {
200
+ if (await adoptRow()) {
201
+ await options.store.updateMessage(rowId, values);
202
+ writes += 1;
203
+ return;
204
+ }
205
+ const inserted = await options.store.appendMessage({
206
+ id: options.messageId,
207
+ threadId: options.threadId,
208
+ role: "assistant",
209
+ ...values
210
+ });
211
+ const insertedId = inserted?.id;
212
+ rowId = typeof insertedId === "string" && insertedId ? insertedId : options.messageId;
213
+ writes += 1;
214
+ }
215
+ function trigger() {
216
+ if (closed) return;
217
+ if (!dirty) return;
218
+ if (inFlight) return;
219
+ const now = Date.now();
220
+ if (lastWriteAt !== 0 && now - lastWriteAt < currentIntervalMs()) return;
221
+ const snapshot = options.snapshot();
222
+ if (!snapshot) return;
223
+ if (!snapshot.content && (!snapshot.parts || snapshot.parts.length === 0)) return;
224
+ dirty = false;
225
+ lastWriteAt = now;
226
+ inFlight = (async () => {
227
+ try {
228
+ const values = await projectValues(snapshot);
229
+ lastBlobBytes = values.parts ? safeStringify(values.parts).length : 0;
230
+ await writeOnce(values);
231
+ } catch (err) {
232
+ log("[chat-routes] incremental assistant persistence failed", {
233
+ messageId: options.messageId,
234
+ error: err instanceof Error ? err.message : String(err)
235
+ });
236
+ } finally {
237
+ inFlight = void 0;
238
+ if (dirty && !closed) trigger();
239
+ }
240
+ })();
241
+ }
242
+ return {
243
+ notify(event) {
244
+ if (closed) return;
245
+ if (isDraftContentEvent(event)) dirty = true;
246
+ trigger();
247
+ },
248
+ async close() {
249
+ closed = true;
250
+ if (inFlight) await inFlight;
251
+ },
252
+ async finalize(values) {
253
+ closed = true;
254
+ if (inFlight) await inFlight;
255
+ await writeOnce(values);
256
+ },
257
+ rowId: () => rowId,
258
+ async discard() {
259
+ closed = true;
260
+ if (inFlight) await inFlight;
261
+ if (!options.store.deleteMessage) return;
262
+ try {
263
+ if (!await adoptRow()) return;
264
+ await options.store.deleteMessage(rowId);
265
+ rowId = void 0;
266
+ } catch (err) {
267
+ log("[chat-routes] draft assistant row discard failed", {
268
+ messageId: options.messageId,
269
+ error: err instanceof Error ? err.message : String(err)
270
+ });
271
+ }
272
+ },
273
+ writeCount: () => writes
274
+ };
275
+ }
276
+ function storeSupportsDraftPersistence(store) {
277
+ return typeof store.updateMessage === "function";
278
+ }
279
+ function assistantRowIdForTurn(turnKey) {
280
+ return `assistant:${turnKey}`;
281
+ }
282
+
283
+ // src/chat-routes/turn-routes.ts
97
284
  function failureReasonOf(data) {
98
285
  if (!data) return void 0;
99
286
  const message = data.message ?? data.error ?? data.reason;
@@ -184,6 +371,13 @@ async function* withStreamHeartbeat(source, intervalMs, makeEvent) {
184
371
  }
185
372
  function createChatTurnRoutes(options) {
186
373
  const log = options.log ?? ((message, meta) => console.error(message, meta ?? ""));
374
+ const draftStore = options.store;
375
+ if (options.incrementalPersistence && !storeSupportsDraftPersistence(draftStore)) {
376
+ throw new Error(
377
+ "incrementalPersistence requires a store with updateMessage() \u2014 `/chat-store`'s createChatStore has it; a product store must implement it or omit the option"
378
+ );
379
+ }
380
+ const draftTuning = options.incrementalPersistence === false || !storeSupportsDraftPersistence(draftStore) ? null : options.incrementalPersistence ?? {};
187
381
  async function turn(request, ctx) {
188
382
  const [rawBody, badBody] = await parseJsonObjectBody(request);
189
383
  if (badBody) return badBody;
@@ -204,7 +398,18 @@ function createChatTurnRoutes(options) {
204
398
  content: m.content,
205
399
  parts: m.parts ?? null
206
400
  }));
207
- const chatTurn = resolveChatTurn({ existingMessages, userContent: content, turnId });
401
+ let hasRunningTurn = false;
402
+ if (draftTuning && existingMessages.at(-1)?.role === "assistant") {
403
+ try {
404
+ hasRunningTurn = (await options.turnStore.listRunning?.(payload.threadId) ?? []).length > 0;
405
+ } catch (err) {
406
+ log("[chat-routes] listRunning probe failed; treating the thread as idle", {
407
+ threadId: payload.threadId,
408
+ error: err instanceof Error ? err.message : String(err)
409
+ });
410
+ }
411
+ }
412
+ const chatTurn = resolveChatTurn({ existingMessages, userContent: content, turnId, hasRunningTurn });
208
413
  const identity = {
209
414
  tenantId,
210
415
  sessionId: payload.threadId,
@@ -250,6 +455,7 @@ function createChatTurnRoutes(options) {
250
455
  lockHandle = acquired.handle;
251
456
  }
252
457
  let producer;
458
+ let draft;
253
459
  let runFailed = false;
254
460
  let lastFailureData;
255
461
  let turnStartedAtMs = 0;
@@ -318,6 +524,25 @@ function createChatTurnRoutes(options) {
318
524
  });
319
525
  const turnMarker = { type: "turn", turnId: turnStreamId };
320
526
  await tap.onEvent(turnMarker);
527
+ if (draftTuning) {
528
+ draft = createAssistantDraftWriter({
529
+ ...draftTuning,
530
+ store: draftStore,
531
+ threadId: payload.threadId,
532
+ messageId: options.draftMessageId ? options.draftMessageId({ identity, executionId, threadId: payload.threadId }) : assistantRowIdForTurn(executionId),
533
+ snapshot: () => {
534
+ if (!producer) return null;
535
+ return {
536
+ content: producer.finalText(),
537
+ ...producer.draftParts ? { parts: producer.draftParts() } : {},
538
+ ...producer.usage ? { usage: producer.usage() } : {},
539
+ ...producer.model ? { model: producer.model } : {}
540
+ };
541
+ },
542
+ ...options.transformFinalText ? { transformText: options.transformFinalText } : {},
543
+ log
544
+ });
545
+ }
321
546
  turnStartedAtMs = Date.now();
322
547
  turnStarted = true;
323
548
  if (options.lifecycle?.onTurnStart) {
@@ -363,6 +588,7 @@ function createChatTurnRoutes(options) {
363
588
  lastFailureData = event.data;
364
589
  }
365
590
  await tap.onEvent(event);
591
+ draft?.notify(event);
366
592
  if (options.onEvent) await options.onEvent(event, context);
367
593
  },
368
594
  ...options.transformFinalText ? { transformFinalText: options.transformFinalText } : {},
@@ -374,11 +600,12 @@ function createChatTurnRoutes(options) {
374
600
  )
375
601
  ) : rawParts;
376
602
  const parts = projected ? toChatMessageParts(projected) : void 0;
377
- if (!finalText.trim() && (!parts || parts.length === 0)) return;
603
+ if (!finalText.trim() && (!parts || parts.length === 0)) {
604
+ await draft?.discard();
605
+ return;
606
+ }
378
607
  const usage = producer?.usage?.() ?? {};
379
- await options.store.appendMessage({
380
- threadId: payload.threadId,
381
- role: "assistant",
608
+ const values = {
382
609
  content: finalText,
383
610
  ...parts && parts.length > 0 ? { parts } : {},
384
611
  ...producer?.model ? { model: producer.model } : {},
@@ -388,6 +615,15 @@ function createChatTurnRoutes(options) {
388
615
  ...usage.cacheReadTokens !== void 0 ? { cacheReadTokens: usage.cacheReadTokens } : {},
389
616
  ...usage.cacheWriteTokens !== void 0 ? { cacheWriteTokens: usage.cacheWriteTokens } : {},
390
617
  ...usage.costUsd !== void 0 ? { costUsd: usage.costUsd } : {}
618
+ };
619
+ if (draft) {
620
+ await draft.finalize(values);
621
+ return;
622
+ }
623
+ await options.store.appendMessage({
624
+ threadId: payload.threadId,
625
+ role: "assistant",
626
+ ...values
391
627
  });
392
628
  },
393
629
  ...options.onTurnComplete ? {
@@ -424,6 +660,7 @@ function createChatTurnRoutes(options) {
424
660
  });
425
661
  }
426
662
  const failed = runFailed || drainError !== void 0;
663
+ await draft?.close();
427
664
  try {
428
665
  await tap.done(failed ? "error" : "complete");
429
666
  } catch (err) {
@@ -947,6 +1184,12 @@ ${diagnostic.userMessage}` : diagnostic.userMessage;
947
1184
  finalizeAssistantParts(partOrder, partMap, fullText),
948
1185
  interactionOutcome
949
1186
  ),
1187
+ // Mid-stream snapshot for incremental persistence: the same accumulators,
1188
+ // WITHOUT the two completion-time settlements (`terminalizeDanglingTool*`
1189
+ // and `finalizePendingInteractionParts`). A running tool and an unanswered
1190
+ // ask are the correct live state; settling them early would persist
1191
+ // phantom failures the final write then reverses.
1192
+ draftParts: () => draftAssistantParts(partOrder, partMap, fullText),
950
1193
  usage: () => usage,
951
1194
  ...options.model ? { model: options.model } : {}
952
1195
  };
@@ -973,6 +1216,58 @@ function cachedResultFrom(final) {
973
1216
  }
974
1217
  async function runDetachedTurn(opts) {
975
1218
  const { store, turnId, scopeId } = opts;
1219
+ let producer;
1220
+ let draft;
1221
+ if (opts.persist) {
1222
+ const { store: persistStore, threadId, messageId, transformText, ...tuning } = opts.persist;
1223
+ if (!storeSupportsDraftPersistence(persistStore)) {
1224
+ throw new Error(
1225
+ "runDetachedTurn persist requires a store with updateMessage() \u2014 `/chat-store`'s createChatStore has it"
1226
+ );
1227
+ }
1228
+ draft = createAssistantDraftWriter({
1229
+ ...tuning,
1230
+ store: persistStore,
1231
+ threadId,
1232
+ messageId: messageId ?? assistantRowIdForTurn(turnId),
1233
+ snapshot: () => producer ? {
1234
+ content: producer.finalText?.() ?? "",
1235
+ ...producer.draftParts ? { parts: producer.draftParts() } : {},
1236
+ ...producer.usage ? { usage: producer.usage() } : {},
1237
+ ...opts.model ? { model: opts.model } : {}
1238
+ } : null,
1239
+ ...transformText ? { transformText } : {},
1240
+ ...opts.log ? { log: opts.log } : {}
1241
+ });
1242
+ }
1243
+ const settleRow = async (result) => {
1244
+ if (!draft) return result;
1245
+ const transform = opts.persist?.transformText;
1246
+ const content = transform ? await transform(result.text) : result.text;
1247
+ const rawParts = transform ? await Promise.all(
1248
+ result.parts.map(
1249
+ async (part) => String(part.type ?? "") === "text" ? { ...part, text: await transform(String(part.text ?? "")) } : part
1250
+ )
1251
+ ) : result.parts;
1252
+ const parts2 = toChatMessageParts(rawParts);
1253
+ if (!content.trim() && parts2.length === 0) {
1254
+ await draft.discard();
1255
+ return { ...result, messageId: null };
1256
+ }
1257
+ const values = {
1258
+ content,
1259
+ ...parts2.length > 0 ? { parts: parts2 } : {},
1260
+ ...opts.model ? { model: opts.model } : {},
1261
+ ...result.usage.inputTokens !== void 0 ? { inputTokens: result.usage.inputTokens } : {},
1262
+ ...result.usage.outputTokens !== void 0 ? { outputTokens: result.usage.outputTokens } : {},
1263
+ ...result.usage.reasoningTokens !== void 0 ? { reasoningTokens: result.usage.reasoningTokens } : {},
1264
+ ...result.usage.cacheReadTokens !== void 0 ? { cacheReadTokens: result.usage.cacheReadTokens } : {},
1265
+ ...result.usage.cacheWriteTokens !== void 0 ? { cacheWriteTokens: result.usage.cacheWriteTokens } : {},
1266
+ ...result.usage.costUsd !== void 0 ? { costUsd: result.usage.costUsd } : {}
1267
+ };
1268
+ await draft.finalize(values);
1269
+ return { ...result, messageId: draft.rowId() ?? null };
1270
+ };
976
1271
  const completed = async () => {
977
1272
  if (!opts.completedResult) return null;
978
1273
  try {
@@ -988,14 +1283,14 @@ async function runDetachedTurn(opts) {
988
1283
  } catch (err) {
989
1284
  opts.log?.("[chat-routes] runDetachedTurn getStatus failed; treating as no prior", { turnId, err: String(err) });
990
1285
  }
991
- if (prior === "complete") return cachedResultFrom(await completed());
1286
+ if (prior === "complete") return await settleRow(cachedResultFrom(await completed()));
992
1287
  if (prior === "running") {
993
1288
  const final = await completed();
994
1289
  if (final) {
995
1290
  await store.setStatus(turnId, "complete", scopeId).catch((err) => {
996
1291
  opts.log?.("[chat-routes] runDetachedTurn failed to settle a completed running turn", { turnId, err: String(err) });
997
1292
  });
998
- return cachedResultFrom(final);
1293
+ return await settleRow(cachedResultFrom(final));
999
1294
  }
1000
1295
  if (opts.resetBuffer) {
1001
1296
  await opts.resetBuffer(turnId).catch((err) => {
@@ -1012,7 +1307,7 @@ async function runDetachedTurn(opts) {
1012
1307
  coalesce: opts.coalesce ?? coalesceDeltas
1013
1308
  });
1014
1309
  await tap.onEvent({ type: "turn", turnId });
1015
- const producer = createSandboxChatProducer({
1310
+ producer = createSandboxChatProducer({
1016
1311
  events: opts.events,
1017
1312
  model: opts.model,
1018
1313
  isRenderableInteraction: opts.isRenderableInteraction,
@@ -1026,11 +1321,14 @@ async function runDetachedTurn(opts) {
1026
1321
  const type = ev.type;
1027
1322
  if (typeof type === "string" && TERMINAL_ERROR_TYPES.has(type)) runError = errorMessageOf(ev);
1028
1323
  await tap.onEvent(ev);
1324
+ draft?.notify(ev);
1029
1325
  }
1030
1326
  await tap.done(runError ? "error" : "complete");
1031
1327
  } catch (err) {
1032
1328
  await tap.done("error").catch(() => {
1033
1329
  });
1330
+ await draft?.close().catch(() => {
1331
+ });
1034
1332
  throw err;
1035
1333
  }
1036
1334
  const text = producer.finalText?.() ?? "";
@@ -1040,11 +1338,11 @@ async function runDetachedTurn(opts) {
1040
1338
  const final = await completed();
1041
1339
  if (final?.usage) usage = { ...usage, ...final.usage };
1042
1340
  if (!text && final?.text) {
1043
- return { state: "completed", text: final.text, parts: final.parts ?? parts, usage, cached: false };
1341
+ return await settleRow({ state: "completed", text: final.text, parts: final.parts ?? parts, usage, cached: false });
1044
1342
  }
1045
1343
  }
1046
- if (runError) return { state: "failed", text, parts, usage, error: runError, cached: false };
1047
- return { state: "completed", text, parts, usage, cached: false };
1344
+ if (runError) return await settleRow({ state: "failed", text, parts, usage, error: runError, cached: false });
1345
+ return await settleRow({ state: "completed", text, parts, usage, cached: false });
1048
1346
  }
1049
1347
 
1050
1348
  // src/chat-routes/durable-projection.ts
@@ -1755,6 +2053,7 @@ export {
1755
2053
  UPLOAD_INLINE_MAX_BYTES,
1756
2054
  UPLOAD_MAX_FILE_BYTES,
1757
2055
  assertPromptPartsWithinCap,
2056
+ assistantRowIdForTurn,
1758
2057
  attachmentSizeErrorMessage,
1759
2058
  attachmentTotalSizeErrorMessage,
1760
2059
  base64WireLen,
@@ -1763,6 +2062,7 @@ export {
1763
2062
  bytesToBase64,
1764
2063
  chatTurnRequestInit,
1765
2064
  checkAttachmentType,
2065
+ createAssistantDraftWriter,
1766
2066
  createAttachmentUploadRoute,
1767
2067
  createChatTurnRoutes,
1768
2068
  createSandboxChatProducer,
@@ -1771,6 +2071,7 @@ export {
1771
2071
  defaultValidateAttachmentPath,
1772
2072
  fileMentionsToParts,
1773
2073
  formatBytes,
2074
+ isDraftContentEvent,
1774
2075
  mediaTypeForMentionPath,
1775
2076
  mentionKindForPath,
1776
2077
  parseChatTurnParts,
@@ -1784,6 +2085,7 @@ export {
1784
2085
  sanitizeUploadFilename,
1785
2086
  sniffBinary,
1786
2087
  sniffMimeFromName,
2088
+ storeSupportsDraftPersistence,
1787
2089
  validateSandboxMentionPath,
1788
2090
  withDurableChatProjection
1789
2091
  };