agents 0.22.0 → 0.24.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.
Files changed (215) hide show
  1. package/README.md +20 -1
  2. package/dist/{agent-routing-CnkaHb-v.d.ts → agent-routing-B2XLNMxq.d.ts} +1094 -443
  3. package/dist/agent-routing.d.ts +1 -1
  4. package/dist/agent-tool-types.d.ts +26 -26
  5. package/dist/{agent-tools-C0XJqWzB.d.ts → agent-tools-Dj5Mzlw7.d.ts} +7 -7
  6. package/dist/agent-tools.d.ts +21 -21
  7. package/dist/browser/ai.js +4 -1
  8. package/dist/browser/ai.js.map +1 -1
  9. package/dist/browser/tanstack-ai.js.map +1 -1
  10. package/dist/callable-decorator-DP__HhBA.d.ts +72 -0
  11. package/dist/callable-decorator.d.ts +18 -0
  12. package/dist/callable-decorator.js +71 -0
  13. package/dist/callable-decorator.js.map +1 -0
  14. package/dist/{capability-BjSKYpzg.js → capability-B4WbF81e.js} +2 -1
  15. package/dist/capability-B4WbF81e.js.map +1 -0
  16. package/dist/capability-runner-BUBa6Ake.d.ts +484 -0
  17. package/dist/channel-Bnm4S7T2.d.ts +491 -0
  18. package/dist/channels/ai-sdk.d.ts +46 -0
  19. package/dist/channels/ai-sdk.js +120 -0
  20. package/dist/channels/ai-sdk.js.map +1 -0
  21. package/dist/channels/email.d.ts +95 -0
  22. package/dist/channels/email.js +323 -0
  23. package/dist/channels/email.js.map +1 -0
  24. package/dist/channels/index.d.ts +233 -0
  25. package/dist/channels/index.js +608 -0
  26. package/dist/channels/index.js.map +1 -0
  27. package/dist/channels/slack.d.ts +140 -0
  28. package/dist/channels/slack.js +614 -0
  29. package/dist/channels/slack.js.map +1 -0
  30. package/dist/channels/tanstack-ai.d.ts +39 -0
  31. package/dist/channels/tanstack-ai.js +17 -0
  32. package/dist/channels/tanstack-ai.js.map +1 -0
  33. package/dist/channels/telegram.d.ts +106 -0
  34. package/dist/channels/telegram.js +427 -0
  35. package/dist/channels/telegram.js.map +1 -0
  36. package/dist/channels/voice.d.ts +45 -0
  37. package/dist/channels/voice.js +122 -0
  38. package/dist/channels/voice.js.map +1 -0
  39. package/dist/chat/index.d.ts +2319 -2010
  40. package/dist/chat/index.js +892 -518
  41. package/dist/chat/index.js.map +1 -1
  42. package/dist/chat-sdk/index.d.ts +7 -7
  43. package/dist/chat-sdk/index.js +1 -1
  44. package/dist/client-BxdkCuVy.d.ts +411 -0
  45. package/dist/client-C9DAjZIY.js +518 -0
  46. package/dist/client-C9DAjZIY.js.map +1 -0
  47. package/dist/{client-jagG8a9_.js → client-fyQrLGkD.js} +2 -2
  48. package/dist/{client-jagG8a9_.js.map → client-fyQrLGkD.js.map} +1 -1
  49. package/dist/client.d.ts +27 -283
  50. package/dist/client.js +2 -274
  51. package/dist/context/index.d.ts +216 -0
  52. package/dist/context/index.js +454 -0
  53. package/dist/context/index.js.map +1 -0
  54. package/dist/current-agent-C4w86kcN.d.ts +93 -0
  55. package/dist/current-agent-DhoDkSnH.js.map +1 -1
  56. package/dist/{diagnostics-CaBjfz4J.js → diagnostics-BzvaX2UT.js} +5 -1
  57. package/dist/diagnostics-BzvaX2UT.js.map +1 -0
  58. package/dist/diagnostics-C4jcz3VK.js +360 -0
  59. package/dist/diagnostics-C4jcz3VK.js.map +1 -0
  60. package/dist/durable-object-lifecycle-ei7M7lqB.d.ts +157 -0
  61. package/dist/index-BB0kqhIz.d.ts +101 -0
  62. package/dist/index-BVVgDSdq.d.ts +1 -0
  63. package/dist/index-Dlgtd3Mj.d.ts +78 -0
  64. package/dist/index-XDkuQ7zm.d.ts +89 -0
  65. package/dist/{index-DcSAZKsB.d.ts → index-YSKgfgg9.d.ts} +3 -1
  66. package/dist/index.d.ts +92 -83
  67. package/dist/index.js +4 -3
  68. package/dist/ingress-BfetZbMO.js +83 -0
  69. package/dist/ingress-BfetZbMO.js.map +1 -0
  70. package/dist/internal-CYlgHl1l.js +59 -0
  71. package/dist/internal-CYlgHl1l.js.map +1 -0
  72. package/dist/internal_context.d.ts +1 -1
  73. package/dist/lifecycle/index.d.ts +39 -19
  74. package/dist/lifecycle/index.js +2 -2
  75. package/dist/lifecycle-Mm_jQh7r.js +1314 -0
  76. package/dist/lifecycle-Mm_jQh7r.js.map +1 -0
  77. package/dist/mcp/client/index.d.ts +20 -20
  78. package/dist/mcp/client/index.js +1 -1
  79. package/dist/mcp/index.d.ts +35 -35
  80. package/dist/mcp/index.js +2 -2
  81. package/dist/observability/index.d.ts +1 -1
  82. package/dist/observability/index.js +1 -1
  83. package/dist/queue/index.d.ts +123 -0
  84. package/dist/queue/index.js +2 -0
  85. package/dist/queue-BC9F5cUo.js +406 -0
  86. package/dist/queue-BC9F5cUo.js.map +1 -0
  87. package/dist/react.d.ts +34 -16
  88. package/dist/react.js +31 -8
  89. package/dist/react.js.map +1 -1
  90. package/dist/{retries-CAvxtG9d.d.ts → retries-D9B2UCq3.d.ts} +23 -6
  91. package/dist/retries.d.ts +10 -6
  92. package/dist/retries.js +21 -1
  93. package/dist/retries.js.map +1 -1
  94. package/dist/routing/index.d.ts +137 -0
  95. package/dist/routing/index.js +244 -0
  96. package/dist/routing/index.js.map +1 -0
  97. package/dist/sanitize-D9TujEK8.js +79 -0
  98. package/dist/sanitize-D9TujEK8.js.map +1 -0
  99. package/dist/{scheduler-DQoTGoAW.d.ts → scheduler-BGq6M5Kd.d.ts} +20 -21
  100. package/dist/scheduler-D_KHqxBa.js +658 -0
  101. package/dist/scheduler-D_KHqxBa.js.map +1 -0
  102. package/dist/schedules/index.d.ts +1 -1
  103. package/dist/schedules/index.js +1 -1
  104. package/dist/sentence-chunker-BAidJ4DA.d.ts +68 -0
  105. package/dist/serializable.d.ts +1 -1
  106. package/dist/sessions/index.d.ts +441 -0
  107. package/dist/sessions/index.js +2063 -0
  108. package/dist/sessions/index.js.map +1 -0
  109. package/dist/skills/index.d.ts +99 -0
  110. package/dist/skills/index.js +254 -5
  111. package/dist/skills/index.js.map +1 -1
  112. package/dist/{src-5W6JNKVb.js → src-BNU3ZiJM.js} +1524 -1441
  113. package/dist/src-BNU3ZiJM.js.map +1 -0
  114. package/dist/state/index.d.ts +6 -0
  115. package/dist/state/index.js +137 -0
  116. package/dist/state/index.js.map +1 -0
  117. package/dist/streams/index.d.ts +120 -0
  118. package/dist/streams/index.js +107 -0
  119. package/dist/streams/index.js.map +1 -0
  120. package/dist/streams-BcE8JP2X.d.ts +370 -0
  121. package/dist/streams-CCPRV6dt.js +709 -0
  122. package/dist/streams-CCPRV6dt.js.map +1 -0
  123. package/dist/sub-routing.d.ts +12 -12
  124. package/dist/surface-bZZJqBka.js +17 -0
  125. package/dist/surface-bZZJqBka.js.map +1 -0
  126. package/dist/tasks/index.d.ts +64 -0
  127. package/dist/tasks/index.js +2 -0
  128. package/dist/tasks-D4nLqVSI.js +1656 -0
  129. package/dist/tasks-D4nLqVSI.js.map +1 -0
  130. package/dist/tasks-V2lCT8ZV.d.ts +517 -0
  131. package/dist/text-segment-joiner-BtAFQSA_.js +57 -0
  132. package/dist/text-segment-joiner-BtAFQSA_.js.map +1 -0
  133. package/dist/text-stream-CpdiKrJB.js +272 -0
  134. package/dist/text-stream-CpdiKrJB.js.map +1 -0
  135. package/dist/tokens-nHAKcN6M.js +52 -0
  136. package/dist/tokens-nHAKcN6M.js.map +1 -0
  137. package/dist/tool-schema-CBjGPrsQ.js +31 -0
  138. package/dist/tool-schema-CBjGPrsQ.js.map +1 -0
  139. package/dist/transport-protocol-BxyngQ_R.js +25 -0
  140. package/dist/transport-protocol-BxyngQ_R.js.map +1 -0
  141. package/dist/transport-protocol-ZTew5Wso.d.ts +27 -0
  142. package/dist/types-B7LojTe4.d.ts +202 -0
  143. package/dist/types-DCD6BNZH.d.ts +70 -0
  144. package/dist/types-_Faxb570.d.ts +439 -0
  145. package/dist/voice/client.d.ts +226 -0
  146. package/dist/voice/client.js +932 -0
  147. package/dist/voice/client.js.map +1 -0
  148. package/dist/voice/errors.d.ts +43 -0
  149. package/dist/voice/errors.js +41 -0
  150. package/dist/voice/errors.js.map +1 -0
  151. package/dist/voice/index.d.ts +271 -0
  152. package/dist/voice/index.js +1812 -0
  153. package/dist/voice/index.js.map +1 -0
  154. package/dist/voice/react.d.ts +167 -0
  155. package/dist/voice/react.js +234 -0
  156. package/dist/voice/react.js.map +1 -0
  157. package/dist/voice/sfu.d.ts +71 -0
  158. package/dist/voice/sfu.js +157 -0
  159. package/dist/voice/sfu.js.map +1 -0
  160. package/dist/voice/text.d.ts +6 -0
  161. package/dist/voice/text.js +2 -0
  162. package/dist/voice/types.d.ts +58 -0
  163. package/dist/voice/types.js +18 -0
  164. package/dist/voice/types.js.map +1 -0
  165. package/dist/voice/workers-ai.d.ts +136 -0
  166. package/dist/voice/workers-ai.js +568 -0
  167. package/dist/voice/workers-ai.js.map +1 -0
  168. package/dist/websockets/index.d.ts +301 -0
  169. package/dist/websockets/index.js +3 -0
  170. package/dist/websockets-D7IzWbZb.js +1005 -0
  171. package/dist/websockets-D7IzWbZb.js.map +1 -0
  172. package/dist/workflow-types.d.ts +25 -25
  173. package/dist/workflows.d.ts +21 -21
  174. package/dist/workflows.js +1 -1
  175. package/docs/agent-class.md +4 -4
  176. package/docs/agent-tools.md +2 -1
  177. package/docs/channels.md +323 -0
  178. package/docs/chat-agents.md +6 -13
  179. package/docs/client-sdk.md +30 -0
  180. package/docs/context.md +131 -0
  181. package/docs/index.md +16 -13
  182. package/docs/lifecycle.md +179 -57
  183. package/docs/long-running-agents.md +2 -2
  184. package/docs/mcp-servers.md +5 -1
  185. package/docs/queue.md +135 -241
  186. package/docs/resumable-streaming.md +1 -1
  187. package/docs/retries.md +1 -1
  188. package/docs/routing.md +105 -0
  189. package/docs/sessions.md +237 -871
  190. package/docs/streams.md +213 -0
  191. package/docs/sub-agents.md +184 -124
  192. package/docs/tasks.md +246 -0
  193. package/docs/voice.md +745 -0
  194. package/package.json +126 -14
  195. package/dist/capability-BjSKYpzg.js.map +0 -1
  196. package/dist/capability-runner-CvHGZqUu.d.ts +0 -150
  197. package/dist/client.js.map +0 -1
  198. package/dist/compaction-helpers-iiKMr2TQ.js +0 -340
  199. package/dist/compaction-helpers-iiKMr2TQ.js.map +0 -1
  200. package/dist/compaction-helpers-wUz6M3us.d.ts +0 -621
  201. package/dist/current-agent-CuMErtly.d.ts +0 -283
  202. package/dist/diagnostics-CaBjfz4J.js.map +0 -1
  203. package/dist/durable-object-lifecycle-D6nNQJJd.js +0 -862
  204. package/dist/durable-object-lifecycle-D6nNQJJd.js.map +0 -1
  205. package/dist/experimental/memory/session/index.d.ts +0 -671
  206. package/dist/experimental/memory/session/index.js +0 -2379
  207. package/dist/experimental/memory/session/index.js.map +0 -1
  208. package/dist/experimental/memory/utils/index.d.ts +0 -96
  209. package/dist/experimental/memory/utils/index.js +0 -79
  210. package/dist/experimental/memory/utils/index.js.map +0 -1
  211. package/dist/scheduler-CR9RHGos.js +0 -857
  212. package/dist/scheduler-CR9RHGos.js.map +0 -1
  213. package/dist/src-5W6JNKVb.js.map +0 -1
  214. package/dist/tool-output-truncation-CNnnGZQ3.js +0 -98
  215. package/dist/tool-output-truncation-CNnnGZQ3.js.map +0 -1
@@ -1,67 +1,111 @@
1
- import { i as _classPrivateFieldInitSpec, n as _classPrivateFieldSet2, r as _assertClassBrand, t as _classPrivateFieldGet2 } from "../classPrivateFieldGet2-DZBYAB34.js";
2
- import { t as _classPrivateMethodInitSpec } from "../classPrivateMethodInitSpec-qMjJ6sHQ.js";
1
+ import { isPlatformFailure, tryN } from "../retries.js";
3
2
  import { i as normalizeToolInput, n as getPartialStreamText, r as isReplayChunk, t as applyChunkToParts } from "../message-builder-BymO4N_D.js";
4
3
  import { i as interceptAgentToolBroadcast, n as applyAgentToolEvent, r as createAgentToolEventState, t as AgentToolProgressEmitter } from "../agent-tools-y7zLfw4Q.js";
5
- import { t as truncateToolOutput } from "../tool-output-truncation-CNnnGZQ3.js";
4
+ import { t as TextSegmentJoiner } from "../text-segment-joiner-BtAFQSA_.js";
5
+ import { n as sanitizeMessage, t as byteLength } from "../sanitize-D9TujEK8.js";
6
6
  import { i as StreamAccumulator, n as STREAM_RESUME_NONE_REASONS, r as transition, t as CHAT_MESSAGE_TYPES } from "../protocol-B0nh6KNf.js";
7
+ import { n as Streams } from "../streams-CCPRV6dt.js";
7
8
  import { t as MessageType } from "../wire-types-CnMt6_HR.js";
8
9
  import { jsonSchema, tool } from "ai";
9
10
  import { nanoid } from "nanoid";
10
- //#region src/chat/sanitize.ts
11
- const textEncoder$1 = new TextEncoder();
12
- /** Maximum serialized message size before compaction (bytes). 1.8MB with headroom below SQLite's 2MB limit. */
13
- const ROW_MAX_BYTES = 18e5;
14
- /** Measure UTF-8 byte length of a string. */
15
- function byteLength(s) {
16
- return textEncoder$1.encode(s).byteLength;
17
- }
18
- /**
19
- * Sanitize a message for persistence by removing ephemeral provider-specific
20
- * data that should not be stored or sent back in subsequent requests.
21
- *
22
- * 1. Strips OpenAI ephemeral fields (itemId, reasoningEncryptedContent)
23
- * 2. Filters truly empty reasoning parts (no text, no remaining providerMetadata)
24
- */
25
- function sanitizeMessage(message) {
26
- const sanitizedParts = message.parts.map((part) => {
27
- let sanitizedPart = part;
28
- if ("providerMetadata" in sanitizedPart && sanitizedPart.providerMetadata && typeof sanitizedPart.providerMetadata === "object" && "openai" in sanitizedPart.providerMetadata) sanitizedPart = stripOpenAIMetadata(sanitizedPart, "providerMetadata");
29
- if ("callProviderMetadata" in sanitizedPart && sanitizedPart.callProviderMetadata && typeof sanitizedPart.callProviderMetadata === "object" && "openai" in sanitizedPart.callProviderMetadata) sanitizedPart = stripOpenAIMetadata(sanitizedPart, "callProviderMetadata");
30
- return sanitizedPart;
31
- }).filter((part) => {
32
- if (part.type === "reasoning") {
33
- const reasoningPart = part;
34
- if (!reasoningPart.text || reasoningPart.text.trim() === "") {
35
- if ("providerMetadata" in reasoningPart && reasoningPart.providerMetadata && typeof reasoningPart.providerMetadata === "object" && Object.keys(reasoningPart.providerMetadata).length > 0) return true;
36
- return false;
37
- }
38
- }
39
- return true;
40
- });
11
+ //#region src/chat/tool-output-truncation.ts
12
+ const DEFAULT_MAX_DEPTH = 8;
13
+ const TRUNCATED_FLAG = "__truncated";
14
+ const TRUNCATED_CHARS = "__truncatedChars";
15
+ function truncateToolOutput(output, maxChars) {
16
+ const original = stringifyForLength(output);
17
+ if (original.length <= maxChars) return {
18
+ output,
19
+ truncated: false,
20
+ originalLength: original.length
21
+ };
41
22
  return {
42
- ...message,
43
- parts: sanitizedParts
23
+ output: truncateValue(output, maxChars, original.length, 0),
24
+ truncated: true,
25
+ originalLength: original.length
44
26
  };
45
27
  }
46
- function stripOpenAIMetadata(part, metadataKey) {
47
- const metadata = part[metadataKey];
48
- if (!metadata?.openai) return part;
49
- const { itemId: _itemId, reasoningEncryptedContent: _rec, ...restOpenai } = metadata.openai;
50
- const hasOtherOpenaiFields = Object.keys(restOpenai).length > 0;
51
- const { openai: _openai, ...restMetadata } = metadata;
52
- let newMetadata;
53
- if (hasOtherOpenaiFields) newMetadata = {
54
- ...restMetadata,
55
- openai: restOpenai
28
+ function truncatedSuffix(originalLength) {
29
+ return `... [truncated ${originalLength} chars]`;
30
+ }
31
+ function truncateValue(value, maxChars, originalLength, depth) {
32
+ if (typeof value === "string") return truncateString(value, maxChars, value.length);
33
+ if (value === null || typeof value !== "object") return value;
34
+ if (depth >= DEFAULT_MAX_DEPTH) return `[Nested output omitted ${truncatedSuffix(originalLength)}]`;
35
+ if (Array.isArray(value)) return truncateArray(value, maxChars, originalLength, depth);
36
+ return truncateObject(value, maxChars, originalLength, depth);
37
+ }
38
+ function truncateArray(value, maxChars, originalLength, depth) {
39
+ const childBudget = childMaxChars(maxChars, value.length);
40
+ const result = value.map((item) => truncateValue(item, childBudget, stringifyForLength(item).length, depth + 1));
41
+ while (result.length > 1 && stringifyForLength(result).length > maxChars) result.pop();
42
+ if (result.length < value.length) result.push(`Array output truncated ${truncatedSuffix(originalLength)}`);
43
+ if (stringifyForLength(result).length > maxChars) return compactArrayMarker(maxChars, originalLength);
44
+ return result;
45
+ }
46
+ function truncateObject(value, maxChars, originalLength, depth) {
47
+ const entries = Object.entries(value);
48
+ const childBudget = childMaxChars(maxChars, entries.length);
49
+ const result = {};
50
+ for (const [key, entryValue] of entries) result[key] = truncateValue(entryValue, childBudget, stringifyForLength(entryValue).length, depth + 1);
51
+ const resultLength = stringifyForLength(result).length;
52
+ if (resultLength <= maxChars) return result;
53
+ shrinkStringFields(result, maxChars);
54
+ if (stringifyForLength(result).length > maxChars) {
55
+ result[TRUNCATED_FLAG] = true;
56
+ result[TRUNCATED_CHARS] = resultLength;
57
+ }
58
+ if (stringifyForLength(result).length > maxChars) return compactObjectMarker(maxChars, originalLength);
59
+ return result;
60
+ }
61
+ function shrinkStringFields(value, maxChars) {
62
+ const stringEntries = Object.entries(value).filter((entry) => typeof entry[1] === "string").sort((a, b) => b[1].length - a[1].length);
63
+ for (const [key, str] of stringEntries) {
64
+ if (stringifyForLength(value).length <= maxChars) return;
65
+ value[key] = truncateString(str, Math.max(0, Math.floor(maxChars / 4)), str.length);
66
+ }
67
+ }
68
+ function truncateString(value, maxChars, originalLength) {
69
+ if (value.length <= maxChars) return value;
70
+ const suffix = truncatedSuffix(originalLength);
71
+ if (maxChars <= suffix.length) return suffix.slice(0, maxChars);
72
+ return `${value.slice(0, maxChars - suffix.length)}${suffix}`;
73
+ }
74
+ function compactObjectMarker(maxChars, originalLength) {
75
+ const marker = {
76
+ [TRUNCATED_FLAG]: true,
77
+ [TRUNCATED_CHARS]: originalLength,
78
+ note: "Tool output omitted because it was too large to preserve structurally."
56
79
  };
57
- else if (Object.keys(restMetadata).length > 0) newMetadata = restMetadata;
58
- const { [metadataKey]: _oldMeta, ...restPart } = part;
59
- if (newMetadata) return {
60
- ...restPart,
61
- [metadataKey]: newMetadata
80
+ if (stringifyForLength(marker).length <= maxChars) return marker;
81
+ return {
82
+ [TRUNCATED_FLAG]: true,
83
+ [TRUNCATED_CHARS]: originalLength
62
84
  };
63
- return restPart;
64
85
  }
86
+ function compactArrayMarker(maxChars, originalLength) {
87
+ const structuredMarker = compactObjectMarker(maxChars, originalLength);
88
+ if (stringifyForLength([structuredMarker]).length <= maxChars) return [structuredMarker];
89
+ const marker = [`Array output omitted because it was too large to preserve structurally ${truncatedSuffix(originalLength)}`];
90
+ if (stringifyForLength(marker).length <= maxChars) return marker;
91
+ return [truncatedSuffix(originalLength).slice(0, maxChars)];
92
+ }
93
+ function childMaxChars(maxChars, childCount) {
94
+ if (childCount <= 1) return maxChars;
95
+ return Math.max(80, Math.floor(maxChars / Math.min(childCount, 10)));
96
+ }
97
+ function stringifyForLength(value) {
98
+ try {
99
+ if (typeof value === "string") return value;
100
+ return JSON.stringify(value) ?? String(value);
101
+ } catch {
102
+ return String(value);
103
+ }
104
+ }
105
+ //#endregion
106
+ //#region src/chat/sanitize.ts
107
+ /** Maximum serialized message size before compaction (bytes). 1.8MB with headroom below SQLite's 2MB limit. */
108
+ const ROW_MAX_BYTES = 18e5;
65
109
  /**
66
110
  * Enforce SQLite row size limits by compacting tool outputs and text parts
67
111
  * when a serialized message exceeds the safety threshold (1.8MB). Shared by
@@ -218,58 +262,6 @@ var TurnQueue = class {
218
262
  }
219
263
  };
220
264
  //#endregion
221
- //#region src/chat/text-segment-joiner.ts
222
- var _hasText = /* @__PURE__ */ new WeakMap();
223
- var _lastTextEndedWithWhitespace = /* @__PURE__ */ new WeakMap();
224
- var _needsBoundarySpace = /* @__PURE__ */ new WeakMap();
225
- var _TextSegmentJoiner_brand = /* @__PURE__ */ new WeakSet();
226
- /**
227
- * @internal Converts structured stream chunks into text and boundary events
228
- * without gluing words across non-text chunks. Sibling-package support for
229
- * `@cloudflare/voice` and `@cloudflare/think`, not a public API.
230
- */
231
- var TextSegmentJoiner = class {
232
- constructor() {
233
- _classPrivateMethodInitSpec(this, _TextSegmentJoiner_brand);
234
- _classPrivateFieldInitSpec(this, _hasText, false);
235
- _classPrivateFieldInitSpec(this, _lastTextEndedWithWhitespace, false);
236
- _classPrivateFieldInitSpec(this, _needsBoundarySpace, false);
237
- }
238
- pushChunk(chunk) {
239
- if (chunk.type === "text-delta") {
240
- const text = textFromChunk(chunk);
241
- return text ? _assertClassBrand(_TextSegmentJoiner_brand, this, _pushText).call(this, text) : [];
242
- }
243
- if (typeof chunk.type !== "string" || !_assertClassBrand(_TextSegmentJoiner_brand, this, _markBoundary).call(this)) return [];
244
- return [{ type: "boundary" }];
245
- }
246
- };
247
- function _markBoundary() {
248
- if (!_classPrivateFieldGet2(_hasText, this) || _classPrivateFieldGet2(_needsBoundarySpace, this)) return false;
249
- _classPrivateFieldSet2(_needsBoundarySpace, this, true);
250
- return true;
251
- }
252
- function _pushText(text) {
253
- const events = [];
254
- if (_classPrivateFieldGet2(_needsBoundarySpace, this) && !_classPrivateFieldGet2(_lastTextEndedWithWhitespace, this) && !/^\s/.test(text)) events.push({
255
- type: "text",
256
- text: " "
257
- });
258
- events.push({
259
- type: "text",
260
- text
261
- });
262
- _classPrivateFieldSet2(_hasText, this, true);
263
- _classPrivateFieldSet2(_lastTextEndedWithWhitespace, this, /\s$/.test(text));
264
- _classPrivateFieldSet2(_needsBoundarySpace, this, false);
265
- return events;
266
- }
267
- function textFromChunk(chunk) {
268
- if (typeof chunk.text === "string") return chunk.text;
269
- if (typeof chunk.delta === "string") return chunk.delta;
270
- return null;
271
- }
272
- //#endregion
273
265
  //#region src/chat/submit-concurrency.ts
274
266
  var SubmitConcurrencyController = class {
275
267
  constructor(options) {
@@ -411,132 +403,337 @@ function isWebSocketClosedSendError(error) {
411
403
  return error instanceof TypeError && error.message.includes("WebSocket send() after close");
412
404
  }
413
405
  //#endregion
406
+ //#region src/chat/replay-frames.ts
407
+ /**
408
+ * Send stored chunk bodies to a connection as replay frames.
409
+ *
410
+ * @returns False when the connection closed mid-replay — the caller leaves
411
+ * its stream state untouched so the next reconnect can retry.
412
+ */
413
+ function sendReplayBodies(connection, requestId, bodies, continuation) {
414
+ for (const body of bodies) if (!sendIfOpen(connection, JSON.stringify({
415
+ body,
416
+ done: false,
417
+ id: requestId,
418
+ type: CHAT_MESSAGE_TYPES.USE_CHAT_RESPONSE,
419
+ replay: true,
420
+ ...continuation && { continuation: true }
421
+ }))) return false;
422
+ return true;
423
+ }
424
+ /**
425
+ * Send one replay control frame: `done: true` ends the replayed stream;
426
+ * `replayComplete` tells a live client to flush accumulated parts and keep
427
+ * listening. Replay frames must mirror what a live client observed,
428
+ * including the continuation flag (#1733).
429
+ */
430
+ function sendReplayControl(connection, requestId, options) {
431
+ return sendIfOpen(connection, JSON.stringify({
432
+ body: "",
433
+ done: options.done,
434
+ id: requestId,
435
+ type: CHAT_MESSAGE_TYPES.USE_CHAT_RESPONSE,
436
+ replay: true,
437
+ ...options.replayComplete && { replayComplete: true },
438
+ ...options.continuation && { continuation: true }
439
+ }));
440
+ }
441
+ //#endregion
414
442
  //#region src/chat/resumable-stream.ts
415
443
  /**
416
- * ResumableStream: Standalone class for buffering, persisting, and replaying
417
- * stream chunks in SQLite. Extracted from AIChatAgent to separate concerns.
444
+ * ResumableStream: chat's producer-side coalescing and wire-protocol replay
445
+ * adapter over the `agents/streams` capability. Chat's in-flight output
446
+ * lives in the shared durable chunk log (`cf_agents_streams` /
447
+ * `cf_agents_stream_blocks`), one stream per turn, tagged with the turn's
448
+ * request id so replay-by-request rides the capability's indexed lookup.
418
449
  *
419
450
  * Handles:
420
- * - Chunk buffering (batched writes to SQLite for performance)
421
- * - Stream lifecycle (start, complete, error)
422
- * - Chunk replay for reconnecting clients
423
- * - Stale stream cleanup
451
+ * - Chunk buffering (packed segments — batched writes for storage-op economy)
452
+ * - Stream lifecycle (start, complete, error) mapped onto Streams settlement
453
+ * - Chunk replay for reconnecting clients (framing in `replay-frames.ts`)
454
+ * - Stale stream cleanup (row-level retention; at most one indexed
455
+ * chunk-tail read per stale live candidate, never a chunk-table scan)
424
456
  * - Active stream restoration after agent restart
425
- */
426
- /** Number of chunks to pack into a single SQLite row before flushing */
457
+ * - One-time migration of legacy `cf_ai_chat_stream_*` tables
458
+ *
459
+ * The adapter's public surface is synchronous and may be constructed before
460
+ * the Lifecycle starts, so it runs on the Streams internal sync aperture; the
461
+ * invariant-bearing writes (append fence, settlement, wakeups, events) go
462
+ * through the capability, so live `streams.read()` consumers and diagnostics
463
+ * observe chat streams like any other stream. The host `sql` handle is used
464
+ * only for chat's own legacy tables during migration.
465
+ */
466
+ /** Number of chunks to pack into a single stored segment before flushing */
427
467
  const CHUNK_BUFFER_SIZE = 10;
428
468
  /** Maximum buffer size to prevent memory issues on rapid reconnections */
429
469
  const CHUNK_BUFFER_MAX_SIZE = 100;
430
470
  /**
431
- * Max accumulated raw chunk bytes packed into one row before forcing a flush.
432
- * The SQLite row limit is 2 MB; packing serializes bodies into a JSON array,
433
- * which re-escapes their contents (quotes/backslashes), so we keep the raw
434
- * total well under the limit to leave generous headroom for escaping overhead.
435
- * A chunk larger than this is flushed as its own (unwrapped) row.
471
+ * Max accumulated raw chunk bytes packed into one segment before forcing a
472
+ * flush. The SQLite row limit is 2 MB; packing serializes bodies into a JSON
473
+ * array, which re-escapes their contents (quotes/backslashes), so we keep the
474
+ * raw total well under the limit to leave generous headroom for escaping
475
+ * overhead. A chunk larger than this is flushed as its own (unwrapped)
476
+ * segment.
436
477
  */
437
478
  const SEGMENT_MAX_BYTES = 512e3;
438
- /** Default cleanup interval for old streams (ms) - every 10 minutes */
439
- const CLEANUP_INTERVAL_MS = 600 * 1e3;
440
479
  /**
441
- * Retention for completed/errored stream buffers, measured from completion.
442
- *
443
- * The assistant message is persisted separately (`cf_ai_chat_agent_messages`),
444
- * so once a stream completes its buffer is no longer the source of truth — it
445
- * is only a brief reconnect-and-replay grace window: long enough to cover a
446
- * client that dropped at the completion boundary and reconnects to replay the
447
- * just-finished stream, and to deliver a pending terminal error frame on a
448
- * resumed stream (#1645). It is deliberately short (not the chat's lifetime)
449
- * so idle/one-off chat DOs don't accumulate stale buffers (#1706).
480
+ * Stored segments per page when replaying a stream's chunk log. Bounds
481
+ * replay memory to one page of segment bodies rather than the whole turn.
450
482
  */
451
- const COMPLETED_RETENTION_MS = 600 * 1e3;
483
+ const REPLAY_PAGE_SEGMENTS = 10;
452
484
  /**
453
485
  * Retention for abandoned `streaming` rows, measured from LAST chunk activity.
454
486
  *
455
- * Generous relative to {@link COMPLETED_RETENTION_MS}: an interrupted turn must
456
- * have ample time to be resumed by a reconnecting client or healed by fiber
457
- * recovery before its buffer is reaped. Only a stream that has produced no
458
- * chunk for this long is treated as truly dead. Keyed off last activity (not
459
- * start time) so a long but still-active stream is never swept mid-flight.
487
+ * An interrupted turn must have ample time to be resumed by a reconnecting
488
+ * client or healed by task replay before its buffer is reaped. Only a stream
489
+ * that has produced no chunk for this long is treated as truly dead. Last
490
+ * activity is decided in two phases — a coarse cutoff on the stream row's
491
+ * `updated_at` (stamped at open, not per append), then one indexed read of
492
+ * the newest chunk's timestamp for rows past it — so a long but still-active
493
+ * stream is never reclaimed mid-flight. Terminal rows carry no such window:
494
+ * a stream that finished is redundant with its persisted message, and the
495
+ * cutover deletes it in the same transaction; leftovers are reclaimed by
496
+ * the next {@link ResumableStream.start}.
460
497
  */
461
498
  const ABANDONED_STREAM_RETENTION_MS = 3600 * 1e3;
462
499
  /** Shared encoder for UTF-8 byte length measurement */
463
500
  const textEncoder = new TextEncoder();
464
501
  /**
465
- * How far ahead (seconds) to schedule the resumable-stream buffer cleanup
466
- * alarm. Set to the short completion-grace window ({@link COMPLETED_RETENTION_MS},
467
- * 10m) so a finished buffer is reclaimed promptly. The re-arm-while-reclaimable
468
- * loop (see {@link cleanupStreamBuffers}) revisits any longer-lived rows — e.g.
469
- * an abandoned in-flight buffer on its 1h window — by waking again each interval
470
- * until they age out, then stops. Driving cleanup from an alarm (rather than
471
- * only piggybacking on the next stream completion) ensures idle/one-off chat
472
- * DOs still reclaim their buffers without waking forever (#1706). Shared by
473
- * `AIChatAgent` and `Think`.
502
+ * Ceiling for one stored chat segment after JSON serialization, and the
503
+ * `maxChunkBytes` the backing Streams capability must be constructed with.
504
+ * Kept under the 2 MB SQLite row limit with headroom for escaping.
474
505
  */
475
- const STREAM_CLEANUP_DELAY_SECONDS = 600;
506
+ const CHAT_STREAM_MAX_CHUNK_BYTES = 19e5;
507
+ /** Maximum serialized chunk body size before skipping storage (bytes). */
508
+ const CHUNK_MAX_BYTES = 18e5;
476
509
  /**
477
- * A stored row body is either a single chunk body (a JSON object string —
478
- * legacy per-chunk rows and single-chunk segments) or a packed segment (a JSON
479
- * array of chunk body strings). Unpack to the individual chunk bodies in order.
480
- *
481
- * Stored chunk bodies are always serialized JSON *objects*, never arrays, so
482
- * `Array.isArray` reliably distinguishes a packed segment from a single body.
510
+ * Construct the Streams capability instance a chat host must install to back
511
+ * its `ResumableStream`: identical to `new Streams()` except for the raised
512
+ * per-chunk ceiling that chat's packed segments require.
483
513
  */
484
- function unpackSegmentBody(rowBody) {
514
+ function createChatStreams() {
515
+ return new Streams({ maxChunkBytes: CHAT_STREAM_MAX_CHUNK_BYTES });
516
+ }
517
+ function toPublicStatus(state) {
518
+ return state === "errored" ? "error" : state;
519
+ }
520
+ function parseChatMetadata(row) {
521
+ if (row.metadata === null) return null;
485
522
  try {
486
- const parsed = JSON.parse(rowBody);
487
- if (Array.isArray(parsed)) return parsed;
523
+ const parsed = JSON.parse(row.metadata);
524
+ if (parsed && parsed.cfChat === 1) return parsed;
488
525
  } catch {}
489
- return [rowBody];
526
+ return null;
490
527
  }
491
- function isMissingMetadataColumnError(error) {
492
- const message = error instanceof Error ? error.message : String(error);
493
- return (message.includes("message_id") || message.includes("is_continuation")) && (message.toLowerCase().includes("no such column") || message.toLowerCase().includes("has no column named"));
528
+ /**
529
+ * A stored segment is either a single chunk body (a JSON string value) or a
530
+ * packed segment (a JSON array of chunk body strings). Unpack to the
531
+ * individual chunk bodies in order.
532
+ */
533
+ function unpackSegment(rawChunkJson) {
534
+ const parsed = JSON.parse(rawChunkJson);
535
+ if (Array.isArray(parsed)) return parsed;
536
+ return [parsed];
494
537
  }
495
- var ResumableStream = class ResumableStream {
496
- constructor(sql) {
497
- this.sql = sql;
538
+ /**
539
+ * The deletion hook each adapter holds on its Streams capability. One
540
+ * adapter per capability: a host whose startup retried constructs the
541
+ * adapter again on the same capability, and the earlier hook must go, or a
542
+ * deleted stream's segments would be retired once per construction.
543
+ */
544
+ const deletionHooks = /* @__PURE__ */ new WeakMap();
545
+ var ResumableStream = class {
546
+ constructor(streams, sql, options = {}) {
498
547
  this._activeStreamId = null;
499
548
  this._activeRequestId = null;
500
- this._segmentIndex = 0;
501
549
  this._isLive = false;
502
550
  this._activeIsContinuation = false;
503
551
  this._chunkBuffer = [];
504
552
  this._chunkBufferBytes = 0;
505
553
  this._isFlushingChunks = false;
506
- this._lastCleanupTime = 0;
507
- this.sql`create table if not exists cf_ai_chat_stream_chunks (
508
- id text primary key,
509
- stream_id text not null,
510
- body text not null,
511
- chunk_index integer not null,
512
- created_at integer not null
513
- )`;
514
- this.sql`create table if not exists cf_ai_chat_stream_metadata (
515
- id text primary key,
516
- request_id text not null,
517
- status text not null,
518
- created_at integer not null,
519
- completed_at integer,
520
- message_id text,
521
- is_continuation integer
522
- )`;
523
- this.sql`create index if not exists idx_stream_chunks_stream_id
524
- on cf_ai_chat_stream_chunks(stream_id, chunk_index)`;
554
+ this._pendingCutover = null;
555
+ this.ops = streams.__DO_NOT_USE_WILL_BREAK__sync();
556
+ this.ops.ensureTables();
557
+ this._sql = sql;
558
+ this._onProgress = options.onProgress;
559
+ this._ensureProgressTable();
560
+ deletionHooks.get(streams)?.();
561
+ deletionHooks.set(streams, this.ops.onDelete((row, cursor) => {
562
+ if (parseChatMetadata(row)) this._retire(cursor);
563
+ }));
564
+ this._migrateLegacyTables(sql);
525
565
  this.restore();
526
566
  }
527
567
  /**
528
- * Add metadata columns for rows created before they existed. Constructors
529
- * intentionally do not run this: most wakes never start a stream, so paying a
530
- * schema-introspection read every time is wasteful. New tables include these
531
- * columns in CREATE TABLE; legacy tables migrate lazily only if a write/read
532
- * discovers the columns are missing.
568
+ * Tell the host the durable part of the marker moved. Called after the
569
+ * write that moved it has left any transaction, never inside one: the
570
+ * host's mirror is an async KV put, which a synchronous transaction
571
+ * would reject.
572
+ */
573
+ _notifyProgress() {
574
+ this._onProgress?.(this._retiredSegments());
575
+ }
576
+ /**
577
+ * One row: `retired` accumulates the segments of deleted streams and
578
+ * explicit credits; `legacy` holds the pre-derivation KV counter, folded
579
+ * in once. They are separate columns so a seed can never swallow
580
+ * segments retired before it landed, and a repeated seed is idempotent.
581
+ */
582
+ _ensureProgressTable() {
583
+ this._sql`
584
+ CREATE TABLE IF NOT EXISTS cf_agents_chat_progress (
585
+ key TEXT PRIMARY KEY,
586
+ retired INTEGER NOT NULL,
587
+ legacy INTEGER NOT NULL DEFAULT 0
588
+ ) WITHOUT ROWID
589
+ `;
590
+ }
591
+ _retiredSegments() {
592
+ const row = this._sql`
593
+ SELECT retired, legacy FROM cf_agents_chat_progress WHERE key = 'chat'
594
+ `[0];
595
+ return row ? row.retired + row.legacy : 0;
596
+ }
597
+ /** Add `segments` to the retired total. One row write; a no-op for zero. */
598
+ _retire(segments) {
599
+ if (segments <= 0) return;
600
+ this._sql`
601
+ INSERT INTO cf_agents_chat_progress (key, retired, legacy)
602
+ VALUES ('chat', ${segments}, 0)
603
+ ON CONFLICT(key) DO UPDATE SET retired = retired + excluded.retired
604
+ `;
605
+ }
606
+ /**
607
+ * Segments a row still accounts for: a live stream's log tail, a settled
608
+ * stream's final cursor (stamped exact at settlement).
609
+ */
610
+ _segmentsOf(row) {
611
+ return row.state === "streaming" ? this.ops.cursor(row.stream_id) : row.chunk_count;
612
+ }
613
+ /**
614
+ * Monotonic count of durably flushed chat segments on this Durable
615
+ * Object, plus explicit credits (see {@link creditProgress}): the recovery
616
+ * engine's forward-progress marker. Advances only when a segment lands in
617
+ * the log — never on a reconnect replay or a recovery re-persist, which
618
+ * read the log without appending — and is untouched by compaction, which
619
+ * rewrites the transcript, not the log. Reads the stream rows plus one
620
+ * log-tail row per live stream: called at incident evaluation, not on the
621
+ * hot path.
622
+ *
623
+ * A chat row leaving the table by any path — this adapter's cutover,
624
+ * reclaim and clear, or the capability's own `delete()` — passes through
625
+ * the deletion hook, so its segments are retired before they are gone
626
+ * and the marker never moves on a deletion.
627
+ */
628
+ progressMarker() {
629
+ let live = 0;
630
+ for (const row of this._chatRows()) live += this._segmentsOf(row);
631
+ return this._retiredSegments() + live;
632
+ }
633
+ /**
634
+ * Credit one unit of forward progress that the log cannot see: a parent
635
+ * forwarding a sub-agent's output (N9) produces no chunks of its own, yet
636
+ * that output is the parent turn advancing. One row write; callers
637
+ * throttle.
638
+ */
639
+ creditProgress() {
640
+ this._retire(1);
641
+ this._notifyProgress();
642
+ }
643
+ /**
644
+ * Carry the pre-derivation KV counter forward: the marker must not read
645
+ * lower after the upgrade than the high-water mark an in-flight incident
646
+ * already recorded, or a progressing turn would look stuck until the log
647
+ * caught up. The counter is never written again, so its value is a
648
+ * constant this folds into its own column by max — idempotent across
649
+ * isolates, and never touching segments retired before the seed landed.
650
+ * A no-op for zero, so a fresh object never writes.
651
+ *
652
+ * The counter already credited a stream that was in flight at the
653
+ * upgrade, and that stream's live segments count again here, so the
654
+ * first read after the upgrade can exceed the counter by those segments.
655
+ * That reads as progress once, and hands an in-flight incident one extra
656
+ * no-progress window; it cannot recur.
533
657
  */
534
- _migrateMetadataColumns() {
535
- const columns = this.sql`
536
- select name from pragma_table_info('cf_ai_chat_stream_metadata')
537
- ` ?? [];
538
- if (!columns.some((column) => column.name === "message_id")) this.sql`alter table cf_ai_chat_stream_metadata add column message_id text`;
539
- if (!columns.some((column) => column.name === "is_continuation")) this.sql`alter table cf_ai_chat_stream_metadata add column is_continuation integer`;
658
+ seedProgress(legacyTotal) {
659
+ if (legacyTotal <= 0) return;
660
+ this._sql`
661
+ INSERT INTO cf_agents_chat_progress (key, retired, legacy)
662
+ VALUES ('chat', 0, ${legacyTotal})
663
+ ON CONFLICT(key) DO UPDATE
664
+ SET legacy = MAX(legacy, excluded.legacy)
665
+ `;
666
+ }
667
+ /**
668
+ * Delete chat rows. The deletion hook folds each row's segments into the
669
+ * retired total in the same synchronous block, retire before delete, so
670
+ * a partial commit could only ever count a stream twice, never lose it.
671
+ */
672
+ _deleteRetiring(rows) {
673
+ if (rows.length === 0) return;
674
+ this.ops.deleteMany(rows.map((row) => row.stream_id));
675
+ this._notifyProgress();
676
+ }
677
+ /**
678
+ * One-time migration of the pre-capability `cf_ai_chat_stream_*` tables
679
+ * into the Streams tables, preserving in-flight resumability across the
680
+ * upgrade (an active stream keeps its id, chunks, and last-activity), then
681
+ * dropping the legacy tables. Tolerates the pre-#1691/#1733 metadata
682
+ * schema (no `message_id` / `is_continuation` columns). The host `sql`
683
+ * handle touches only these chat-owned legacy tables.
684
+ */
685
+ _migrateLegacyTables(sql) {
686
+ const legacyTables = sql`
687
+ SELECT name FROM sqlite_master WHERE type = 'table'
688
+ AND name IN ('cf_ai_chat_stream_metadata', 'cf_ai_chat_stream_chunks')
689
+ `.map((row) => row.name);
690
+ if (legacyTables.length === 0) return;
691
+ if (legacyTables.includes("cf_ai_chat_stream_metadata")) {
692
+ const columns = sql`
693
+ SELECT name FROM pragma_table_info('cf_ai_chat_stream_metadata')
694
+ `.map((row) => row.name);
695
+ const hasMessageId = columns.includes("message_id");
696
+ const hasContinuation = columns.includes("is_continuation");
697
+ const hasChunks = legacyTables.includes("cf_ai_chat_stream_chunks");
698
+ const rows = sql`SELECT * FROM cf_ai_chat_stream_metadata`;
699
+ for (const row of rows) {
700
+ const streamId = String(row.id);
701
+ if (this.ops.getStream(streamId)) continue;
702
+ const metadata = { cfChat: 1 };
703
+ if (hasMessageId && row.message_id != null) metadata.messageId = String(row.message_id);
704
+ if (hasContinuation && row.is_continuation === 1) metadata.isContinuation = 1;
705
+ const chunkRows = hasChunks ? sql`
706
+ SELECT body, created_at FROM cf_ai_chat_stream_chunks
707
+ WHERE stream_id = ${streamId} ORDER BY chunk_index ASC
708
+ ` : [];
709
+ const status = String(row.status);
710
+ const state = status === "error" ? "errored" : status === "completed" ? "completed" : "streaming";
711
+ const createdAt = Number(row.created_at);
712
+ const closedAt = row.completed_at != null ? Number(row.completed_at) : null;
713
+ const lastChunkAt = chunkRows.reduce((max, chunk) => Math.max(max, Number(chunk.created_at)), createdAt);
714
+ this.ops.importStream({
715
+ streamId,
716
+ state,
717
+ tag: String(row.request_id),
718
+ metadata,
719
+ chunkCount: chunkRows.length,
720
+ createdAt,
721
+ updatedAt: closedAt ?? lastChunkAt,
722
+ closedAt
723
+ });
724
+ for (const chunk of chunkRows) {
725
+ const body = String(chunk.body);
726
+ let value = body;
727
+ try {
728
+ const parsed = JSON.parse(body);
729
+ if (Array.isArray(parsed)) value = parsed;
730
+ } catch {}
731
+ this.ops.importChunk(streamId, value, Number(chunk.created_at));
732
+ }
733
+ }
734
+ }
735
+ sql`DROP TABLE IF EXISTS cf_ai_chat_stream_chunks`;
736
+ sql`DROP TABLE IF EXISTS cf_ai_chat_stream_metadata`;
540
737
  }
541
738
  get activeStreamId() {
542
739
  return this._activeStreamId;
@@ -556,53 +753,34 @@ var ResumableStream = class ResumableStream {
556
753
  }
557
754
  /**
558
755
  * Start tracking a new stream for resumable streaming.
559
- * Creates metadata entry in SQLite and sets up tracking state.
756
+ * Creates the backing stream row and sets up tracking state.
560
757
  * @param requestId - The unique ID of the chat request
561
758
  * @returns The generated stream ID
562
759
  */
563
760
  start(requestId, options = {}) {
564
761
  this.flushBuffer();
762
+ this.reclaim();
565
763
  const streamId = nanoid();
566
764
  this._activeStreamId = streamId;
567
765
  this._activeRequestId = requestId;
568
- this._segmentIndex = 0;
569
766
  this._isLive = true;
570
767
  this._activeIsContinuation = options.continuation ?? false;
571
- const messageId = options.messageId ?? null;
572
- try {
573
- this.sql`
574
- insert into cf_ai_chat_stream_metadata (id, request_id, status, created_at, message_id, is_continuation)
575
- values (${streamId}, ${requestId}, 'streaming', ${Date.now()}, ${messageId}, ${this._activeIsContinuation ? 1 : 0})
576
- `;
577
- } catch (error) {
578
- if (!isMissingMetadataColumnError(error)) throw error;
579
- this._migrateMetadataColumns();
580
- this.sql`
581
- insert into cf_ai_chat_stream_metadata (id, request_id, status, created_at, message_id, is_continuation)
582
- values (${streamId}, ${requestId}, 'streaming', ${Date.now()}, ${messageId}, ${this._activeIsContinuation ? 1 : 0})
583
- `;
584
- }
768
+ const metadata = { cfChat: 1 };
769
+ if (options.messageId != null) metadata.messageId = options.messageId;
770
+ if (this._activeIsContinuation) metadata.isContinuation = 1;
771
+ this.ops.insertStream(streamId, requestId, metadata);
585
772
  return streamId;
586
773
  }
587
774
  /**
588
775
  * The assistant message id an orphaned stream was producing — the same id the
589
776
  * live path persists under, so recovery re-associates reconstructed chunks
590
777
  * with the correct message (#1691). Returns null when the row is missing or
591
- * is a legacy row written before the `message_id` column existed.
778
+ * predates message-id tracking.
592
779
  */
593
780
  getStreamMessageId(streamId) {
594
- let rows;
595
- try {
596
- rows = this.sql`
597
- select message_id from cf_ai_chat_stream_metadata
598
- where id = ${streamId}
599
- `;
600
- } catch (error) {
601
- if (!isMissingMetadataColumnError(error)) throw error;
602
- return null;
603
- }
604
- if (!rows || rows.length === 0) return null;
605
- return rows[0].message_id ?? null;
781
+ const row = this.ops.getStream(streamId);
782
+ if (!row) return null;
783
+ return parseChatMetadata(row)?.messageId ?? null;
606
784
  }
607
785
  /**
608
786
  * Mark a stream as completed and flush any pending chunks.
@@ -610,17 +788,65 @@ var ResumableStream = class ResumableStream {
610
788
  */
611
789
  complete(streamId) {
612
790
  this.flushBuffer();
613
- this.sql`
614
- update cf_ai_chat_stream_metadata
615
- set status = 'completed', completed_at = ${Date.now()}
616
- where id = ${streamId}
617
- `;
791
+ this.ops.settle(streamId, "completed", null);
792
+ if (this._pendingCutover === streamId) this._pendingCutover = null;
793
+ this._clearActive();
794
+ }
795
+ /**
796
+ * The producer finished, but leave the row `streaming` for the cutover:
797
+ * the host persists the message and settles the stream in one
798
+ * transaction with {@link cutover}. Until then a crash leaves the stream
799
+ * live — exactly the evidence recovery rebuilds the message from. The
800
+ * host MUST follow with {@link cutover} or {@link finalizePending}.
801
+ */
802
+ finish(streamId) {
803
+ this.flushBuffer();
804
+ this._pendingCutover = streamId;
805
+ this._clearActive();
806
+ }
807
+ /** The stream {@link finish}ed and awaiting its cutover, if any. */
808
+ get pendingCutoverId() {
809
+ return this._pendingCutover;
810
+ }
811
+ /**
812
+ * The cutover: settle the stream, run `persist` (synchronous writes — the
813
+ * message), and delete the stream's rows in one SQLite transaction. A
814
+ * crash leaves either the live stream or the finished message, never
815
+ * neither; nothing is left to sweep. `discard: false` keeps the settled
816
+ * rows (an agent-tool child whose parent still tails them); they are
817
+ * reclaimed by the next {@link start}. The settlement and `persist`
818
+ * writes commit or roll back together.
819
+ */
820
+ cutover(streamId, persist, options = {}) {
821
+ this.flushBuffer();
822
+ const discard = options.discard ?? true;
823
+ const commit = persist;
824
+ const settled = this.ops.settle(streamId, "completed", null, {
825
+ commit,
826
+ discard
827
+ });
828
+ if (settled && discard) this._notifyProgress();
829
+ if (!settled) commit();
830
+ if (this._pendingCutover === streamId) this._pendingCutover = null;
831
+ this._clearActive();
832
+ }
833
+ /**
834
+ * Settle a {@link finish}ed stream that had nothing to persist (no parts,
835
+ * a persist that threw). Idempotent; a no-op when nothing is pending.
836
+ * The pending marker clears only once settlement succeeds, so a caller
837
+ * may retry after a settlement failure — matching {@link cutover}.
838
+ */
839
+ finalizePending() {
840
+ const streamId = this._pendingCutover;
841
+ if (streamId === null) return;
842
+ this.ops.settle(streamId, "completed", null);
843
+ this._pendingCutover = null;
844
+ }
845
+ _clearActive() {
618
846
  this._activeStreamId = null;
619
847
  this._activeRequestId = null;
620
- this._segmentIndex = 0;
621
848
  this._isLive = false;
622
849
  this._activeIsContinuation = false;
623
- this._maybeCleanupOldStreams();
624
850
  }
625
851
  /**
626
852
  * Mark a stream as errored and clean up state.
@@ -628,19 +854,12 @@ var ResumableStream = class ResumableStream {
628
854
  */
629
855
  markError(streamId) {
630
856
  this.flushBuffer();
631
- this.sql`
632
- update cf_ai_chat_stream_metadata
633
- set status = 'error', completed_at = ${Date.now()}
634
- where id = ${streamId}
635
- `;
636
- this._activeStreamId = null;
637
- this._activeRequestId = null;
638
- this._segmentIndex = 0;
639
- this._isLive = false;
640
- this._activeIsContinuation = false;
857
+ this.ops.settle(streamId, "errored", null);
858
+ if (this._pendingCutover === streamId) this._pendingCutover = null;
859
+ this._clearActive();
641
860
  }
642
861
  /**
643
- * Buffer a stream chunk for batch write to SQLite.
862
+ * Buffer a stream chunk for batch write to storage.
644
863
  * Chunks exceeding the row size limit are skipped to prevent crashes.
645
864
  * The chunk is still broadcast to live clients (caller handles that),
646
865
  * but will be missing from replay on reconnection.
@@ -648,8 +867,8 @@ var ResumableStream = class ResumableStream {
648
867
  * @param body - The serialized chunk body
649
868
  */
650
869
  storeChunk(streamId, body) {
651
- const bodyBytes = textEncoder.encode(body).byteLength;
652
- if (bodyBytes > ResumableStream.CHUNK_MAX_BYTES) {
870
+ const bodyBytes = textEncoder.encode(JSON.stringify(body)).byteLength;
871
+ if (bodyBytes > CHUNK_MAX_BYTES) {
653
872
  console.warn(`[ResumableStream] Skipping oversized chunk (${bodyBytes} bytes) to prevent SQLite row limit crash. Live clients still receive it.`);
654
873
  return;
655
874
  }
@@ -663,13 +882,14 @@ var ResumableStream = class ResumableStream {
663
882
  if (this._chunkBuffer.length >= CHUNK_BUFFER_SIZE) this.flushBuffer();
664
883
  }
665
884
  /**
666
- * Flush the buffered chunks to SQLite as a single packed row.
885
+ * Flush the buffered chunks to storage as a single packed segment.
667
886
  * Uses a lock to prevent concurrent flush operations.
668
887
  *
669
- * The whole buffer becomes one row: a single-chunk segment is stored
670
- * unwrapped (legacy object format) so a large chunk avoids array-escaping
671
- * inflation, while a multi-chunk segment stores a JSON array of bodies. This
672
- * collapses N chunk rows into one, cutting rows written / stored / scanned.
888
+ * The whole buffer becomes one stored chunk on the backing stream: a
889
+ * single-chunk segment is stored unwrapped so a large chunk avoids
890
+ * array-escaping inflation, while a multi-chunk segment stores a JSON
891
+ * array of bodies. This collapses N chunk writes into one fenced append,
892
+ * cutting rows written / stored / scanned.
673
893
  */
674
894
  flushBuffer() {
675
895
  if (this._isFlushingChunks || this._chunkBuffer.length === 0) return;
@@ -679,17 +899,33 @@ var ResumableStream = class ResumableStream {
679
899
  this._chunkBuffer = [];
680
900
  this._chunkBufferBytes = 0;
681
901
  const streamId = chunks[0].streamId;
682
- const segmentBody = chunks.length === 1 ? chunks[0].body : JSON.stringify(chunks.map((chunk) => chunk.body));
683
- this.sql`
684
- insert into cf_ai_chat_stream_chunks (id, stream_id, body, chunk_index, created_at)
685
- values (${nanoid()}, ${streamId}, ${segmentBody}, ${this._segmentIndex}, ${Date.now()})
686
- `;
687
- this._segmentIndex++;
902
+ const segment = chunks.length === 1 ? chunks[0].body : chunks.map((chunk) => chunk.body);
903
+ try {
904
+ this.ops.append(streamId, segment);
905
+ } catch {}
688
906
  } finally {
689
907
  this._isFlushingChunks = false;
690
908
  }
691
909
  }
692
910
  /**
911
+ * Stored chunk bodies for one stream, packed segments expanded, in order.
912
+ * A generator over paged reads, so replaying a large turn holds one page
913
+ * of segments in memory instead of the whole stored stream; iteration is
914
+ * synchronous end to end (WebSocket sends don't await), so the pages see
915
+ * a consistent log.
916
+ */
917
+ *_storedBodies(streamId) {
918
+ let next = 0;
919
+ for (;;) {
920
+ const rows = this.ops.readChunks(streamId, next, REPLAY_PAGE_SEGMENTS);
921
+ for (const row of rows) {
922
+ next = row.seq + 1;
923
+ yield* unpackSegment(row.chunk);
924
+ }
925
+ if (rows.length < REPLAY_PAGE_SEGMENTS) return;
926
+ }
927
+ }
928
+ /**
693
929
  * Send stored stream chunks to a connection for replay.
694
930
  * Chunks are marked with replay: true so the client can batch-apply them.
695
931
  *
@@ -699,11 +935,10 @@ var ResumableStream = class ResumableStream {
699
935
  * - **Orphaned stream** (restored from SQLite after hibernation, no reader):
700
936
  * sends chunks + `done` and completes the stream. The caller should
701
937
  * reconstruct and persist the partial message from the stored chunks.
702
- * - **Completed during replay** (defensive): sends chunks + `done`.
703
938
  *
704
- * All sends use {@link sendIfOpen}, so a WebSocket closing mid-replay
705
- * does not throw. If the connection drops while iterating chunks the
706
- * stream is left active so the next reconnect can retry.
939
+ * All sends tolerate a WebSocket closing mid-replay. If the connection
940
+ * drops while iterating chunks the stream is left active so the next
941
+ * reconnect can retry.
707
942
  *
708
943
  * @param connection - The WebSocket connection
709
944
  * @param requestId - The original request ID
@@ -715,66 +950,41 @@ var ResumableStream = class ResumableStream {
715
950
  if (!streamId) return null;
716
951
  this.flushBuffer();
717
952
  const continuation = this._activeIsContinuation;
718
- const chunks = this.sql`
719
- select * from cf_ai_chat_stream_chunks
720
- where stream_id = ${streamId}
721
- order by chunk_index asc
722
- `;
723
- for (const chunk of chunks || []) for (const body of unpackSegmentBody(chunk.body)) if (!sendIfOpen(connection, JSON.stringify({
724
- body,
725
- done: false,
726
- id: requestId,
727
- type: CHAT_MESSAGE_TYPES.USE_CHAT_RESPONSE,
728
- replay: true,
729
- ...continuation && { continuation: true }
730
- }))) return null;
731
- if (this._activeStreamId !== streamId) {
732
- sendIfOpen(connection, JSON.stringify({
733
- body: "",
734
- done: true,
735
- id: requestId,
736
- type: CHAT_MESSAGE_TYPES.USE_CHAT_RESPONSE,
737
- replay: true,
738
- ...continuation && { continuation: true }
739
- }));
740
- return null;
741
- }
953
+ if (!sendReplayBodies(connection, requestId, this._storedBodies(streamId), continuation)) return null;
742
954
  if (!this._isLive) {
743
- sendIfOpen(connection, JSON.stringify({
744
- body: "",
955
+ sendReplayControl(connection, requestId, {
745
956
  done: true,
746
- id: requestId,
747
- type: CHAT_MESSAGE_TYPES.USE_CHAT_RESPONSE,
748
- replay: true,
749
- ...continuation && { continuation: true }
750
- }));
957
+ continuation
958
+ });
751
959
  this.complete(streamId);
752
960
  return streamId;
753
961
  }
754
- sendIfOpen(connection, JSON.stringify({
755
- body: "",
962
+ sendReplayControl(connection, requestId, {
756
963
  done: false,
757
- id: requestId,
758
- type: CHAT_MESSAGE_TYPES.USE_CHAT_RESPONSE,
759
- replay: true,
760
964
  replayComplete: true,
761
- ...continuation && { continuation: true }
762
- }));
965
+ continuation
966
+ });
763
967
  return null;
764
968
  }
969
+ /**
970
+ * Latest CHAT-owned row carrying a request tag. The stream table is
971
+ * shared with application producers and tags are non-unique, so the
972
+ * newest row by tag alone could be an unrelated stream masking chat's
973
+ * recovery evidence — ownership is the `cfChat` metadata marker.
974
+ */
975
+ _latestChatRowByTag(requestId, state) {
976
+ return this.ops.rowsByTag(requestId, state).find((row) => parseChatMetadata(row) !== null);
977
+ }
765
978
  replayCompletedChunksByRequestId(connection, requestId) {
766
- const stream = this._latestStreamForRequest(requestId, "completed");
767
- if (!stream) return false;
768
- const continuation = stream.is_continuation === 1;
769
- if (!this._replayStoredChunks(connection, stream.id, requestId, continuation)) return false;
770
- return sendIfOpen(connection, JSON.stringify({
771
- body: "",
979
+ this.flushBuffer();
980
+ const row = this._latestChatRowByTag(requestId, "completed");
981
+ if (!row) return false;
982
+ const continuation = parseChatMetadata(row)?.isContinuation === 1;
983
+ if (!sendReplayBodies(connection, requestId, this._storedBodies(row.stream_id), continuation)) return false;
984
+ return sendReplayControl(connection, requestId, {
772
985
  done: true,
773
- id: requestId,
774
- type: CHAT_MESSAGE_TYPES.USE_CHAT_RESPONSE,
775
- replay: true,
776
- ...continuation && { continuation: true }
777
- }));
986
+ continuation
987
+ });
778
988
  }
779
989
  /**
780
990
  * Replay the stored chunks of an errored stream for a request, WITHOUT a
@@ -782,9 +992,7 @@ var ResumableStream = class ResumableStream {
782
992
  * frame carrying the durable terminal record's error text, mirroring what a
783
993
  * live client observed (content chunks, then the error). Without this, a
784
994
  * client that missed broadcast frames while disconnected has no other
785
- * channel to the pre-error partial content: the server does not push
786
- * messages on connect, and {@link replayCompletedChunksByRequestId} only
787
- * serves `completed` streams (#1575).
995
+ * channel to the pre-error partial content (#1575).
788
996
  *
789
997
  * Returns true when the caller should proceed to send its terminal frame:
790
998
  * either no errored stream existed (nothing to replay) or its chunks were
@@ -793,40 +1001,46 @@ var ResumableStream = class ResumableStream {
793
1001
  * and the next reconnect retries the whole sequence.
794
1002
  */
795
1003
  replayErroredChunksByRequestId(connection, requestId) {
796
- const stream = this._latestStreamForRequest(requestId, "error");
797
- if (!stream) return true;
798
- return this._replayStoredChunks(connection, stream.id, requestId, stream.is_continuation === 1);
799
- }
800
- /** Latest stream row for a request with the given terminal status. */
801
- _latestStreamForRequest(requestId, status) {
802
1004
  this.flushBuffer();
803
- return this.sql`
804
- select * from cf_ai_chat_stream_metadata
805
- where request_id = ${requestId}
806
- and status = ${status}
807
- order by created_at desc
808
- limit 1
809
- `[0];
1005
+ const row = this._latestChatRowByTag(requestId, "errored");
1006
+ if (!row) return true;
1007
+ return sendReplayBodies(connection, requestId, this._storedBodies(row.stream_id), parseChatMetadata(row)?.isContinuation === 1);
810
1008
  }
811
1009
  /**
812
- * Send a finished stream's stored chunks to a connection as replay frames.
813
- * Returns false if the connection closed mid-replay.
1010
+ * Latest chat stream row for a request regardless of status — the recovery
1011
+ * engines' stream-evidence lookup.
814
1012
  */
815
- _replayStoredChunks(connection, streamId, requestId, continuation = false) {
816
- const chunks = this.sql`
817
- select * from cf_ai_chat_stream_chunks
818
- where stream_id = ${streamId}
819
- order by chunk_index asc
820
- `;
821
- for (const chunk of chunks || []) for (const body of unpackSegmentBody(chunk.body)) if (!sendIfOpen(connection, JSON.stringify({
822
- body,
823
- done: false,
824
- id: requestId,
825
- type: CHAT_MESSAGE_TYPES.USE_CHAT_RESPONSE,
826
- replay: true,
827
- ...continuation && { continuation: true }
828
- }))) return false;
829
- return true;
1013
+ latestStreamInfoForRequest(requestId) {
1014
+ const row = this._latestChatRowByTag(requestId);
1015
+ if (!row) return null;
1016
+ return {
1017
+ id: row.stream_id,
1018
+ status: toPublicStatus(row.state),
1019
+ createdAt: row.created_at
1020
+ };
1021
+ }
1022
+ /**
1023
+ * Latest in-flight chat stream for a request — recoverable-turn evidence.
1024
+ */
1025
+ latestActiveStreamInfoForRequest(requestId) {
1026
+ const row = this._latestChatRowByTag(requestId, "streaming");
1027
+ if (!row) return null;
1028
+ return {
1029
+ id: row.stream_id,
1030
+ createdAt: row.created_at
1031
+ };
1032
+ }
1033
+ /** Every chat-owned stream row, newest first. */
1034
+ _chatRows() {
1035
+ const rows = [];
1036
+ for (const row of this.ops.listRows()) {
1037
+ const chat = parseChatMetadata(row);
1038
+ if (chat) rows.push({
1039
+ ...row,
1040
+ chat
1041
+ });
1042
+ }
1043
+ return rows;
830
1044
  }
831
1045
  /**
832
1046
  * Restore active stream state if the agent was restarted during streaming.
@@ -834,192 +1048,268 @@ var ResumableStream = class ResumableStream {
834
1048
  * lazily in _maybeCleanupOldStreams after recovery has had its chance.
835
1049
  */
836
1050
  restore() {
837
- const activeStreams = this.sql`
838
- select * from cf_ai_chat_stream_metadata
839
- where status = 'streaming'
840
- order by created_at desc
841
- limit 1
842
- `;
843
- if (activeStreams && activeStreams.length > 0) {
844
- const stream = activeStreams[0];
845
- this._activeStreamId = stream.id;
846
- this._activeRequestId = stream.request_id;
847
- this._activeIsContinuation = stream.is_continuation === 1;
848
- const lastChunk = this.sql`
849
- select max(chunk_index) as max_index
850
- from cf_ai_chat_stream_chunks
851
- where stream_id = ${this._activeStreamId}
852
- `;
853
- this._segmentIndex = lastChunk && lastChunk[0]?.max_index != null ? lastChunk[0].max_index + 1 : 0;
1051
+ const row = this._chatRows().find((r) => r.state === "streaming");
1052
+ if (row) {
1053
+ this._activeStreamId = row.stream_id;
1054
+ this._activeRequestId = row.tag;
1055
+ this._activeIsContinuation = row.chat.isContinuation === 1;
854
1056
  }
855
1057
  }
856
1058
  /**
857
- * Clear all stream data (called on chat history clear).
1059
+ * Clear all chat stream data (called on chat history clear). Streams other
1060
+ * producers opened on the same Durable Object are untouched.
858
1061
  */
859
1062
  clearAll() {
860
1063
  this._chunkBuffer = [];
861
1064
  this._chunkBufferBytes = 0;
862
- this.sql`delete from cf_ai_chat_stream_chunks`;
863
- this.sql`delete from cf_ai_chat_stream_metadata`;
1065
+ this._deleteRetiring(this._chatRows());
864
1066
  this._activeStreamId = null;
865
1067
  this._activeRequestId = null;
866
- this._segmentIndex = 0;
867
1068
  this._activeIsContinuation = false;
868
1069
  }
869
1070
  /**
870
- * Drop all stream tables (called on destroy).
1071
+ * Remove all chat stream data (called on destroy). The backing tables
1072
+ * belong to the Streams capability and are shared with other producers,
1073
+ * so this deletes chat's rows rather than dropping tables. Buffered
1074
+ * chunks are dropped (clearAll resets the buffer), not flushed: they
1075
+ * belong to a chat-owned stream this very call deletes, so writing them
1076
+ * first would only pay row writes for rows that die in the same
1077
+ * synchronous block.
871
1078
  */
872
1079
  destroy() {
873
- this.flushBuffer();
874
- this.sql`drop table if exists cf_ai_chat_stream_chunks`;
875
- this.sql`drop table if exists cf_ai_chat_stream_metadata`;
876
- this._activeStreamId = null;
877
- this._activeRequestId = null;
878
- this._activeIsContinuation = false;
1080
+ this.clearAll();
879
1081
  }
880
1082
  /**
881
- * Force a sweep of aged stream buffers now, bypassing the lazy interval
882
- * gate used by {@link _maybeCleanupOldStreams}. Intended to be driven by an
883
- * alarm so idle/hibernated chat DOs still reclaim buffers even when no
884
- * further stream ever completes to trigger the lazy path.
885
- */
886
- cleanup(now = Date.now()) {
887
- this._lastCleanupTime = now;
888
- this._sweepOldStreams(now);
889
- }
890
- /**
891
- * True if any stream rows remain at all. Used by alarm-driven cleanup to
892
- * decide whether to re-arm: once no rows remain there is nothing left to
893
- * sweep, so the DO can stop waking itself.
894
- */
895
- hasReclaimableStreams() {
896
- return (this.sql`
897
- select count(*) as n from cf_ai_chat_stream_metadata
898
- `?.[0]?.n ?? 0) > 0;
899
- }
900
- _maybeCleanupOldStreams() {
901
- const now = Date.now();
902
- if (now - this._lastCleanupTime < CLEANUP_INTERVAL_MS) return;
903
- this._lastCleanupTime = now;
904
- this._sweepOldStreams(now);
905
- }
906
- /** Delete completed/errored buffers past the completion grace window, plus
907
- * abandoned "streaming" rows past the stale-in-flight window. The two use
908
- * different retentions: a completed buffer is redundant with the persisted
909
- * message and needs only a brief replay grace, whereas an in-flight buffer
910
- * must outlive resume/recovery before it is presumed dead. */
911
- _sweepOldStreams(now) {
912
- const completedCutoff = now - COMPLETED_RETENTION_MS;
913
- this.sql`
914
- delete from cf_ai_chat_stream_chunks
915
- where stream_id in (
916
- select id from cf_ai_chat_stream_metadata
917
- where status in ('completed', 'error') and completed_at < ${completedCutoff}
918
- )
919
- `;
920
- this.sql`
921
- delete from cf_ai_chat_stream_metadata
922
- where status in ('completed', 'error') and completed_at < ${completedCutoff}
923
- `;
1083
+ * Delete every chat stream row this Durable Object no longer needs:
1084
+ * finished streams (their messages are persisted; the cutover normally
1085
+ * deletes them in the same transaction, so these are crash leftovers) and
1086
+ * in-flight rows abandoned past {@link ABANDONED_STREAM_RETENTION_MS} by
1087
+ * last chunk activity. Runs on every {@link start}, so nothing needs an
1088
+ * alarm to be reclaimed; a Durable Object that never starts another turn
1089
+ * keeps at most one turn's rows. Streams other producers opened on the
1090
+ * same object are untouched.
1091
+ * @returns How many rows were reclaimed.
1092
+ */
1093
+ reclaim(now = Date.now()) {
924
1094
  const abandonedCutoff = now - ABANDONED_STREAM_RETENTION_MS;
925
- this.sql`
926
- delete from cf_ai_chat_stream_chunks
927
- where stream_id in (
928
- select m.id from cf_ai_chat_stream_metadata m
929
- where m.status = 'streaming'
930
- and coalesce(
931
- (select max(c.created_at) from cf_ai_chat_stream_chunks c
932
- where c.stream_id = m.id),
933
- m.created_at
934
- ) < ${abandonedCutoff}
935
- )
936
- `;
937
- this.sql`
938
- delete from cf_ai_chat_stream_metadata
939
- where id in (
940
- select m.id from cf_ai_chat_stream_metadata m
941
- where m.status = 'streaming'
942
- and coalesce(
943
- (select max(c.created_at) from cf_ai_chat_stream_chunks c
944
- where c.stream_id = m.id),
945
- m.created_at
946
- ) < ${abandonedCutoff}
947
- )
948
- `;
1095
+ const reclaimable = this._chatRows().filter((row) => row.state === "streaming" ? row.stream_id !== this._activeStreamId && row.updated_at < abandonedCutoff && (this.ops.lastChunkAt(row.stream_id) ?? row.updated_at) < abandonedCutoff : true);
1096
+ this._deleteRetiring(reclaimable);
1097
+ return reclaimable.length;
949
1098
  }
950
1099
  /**
951
1100
  * Return the stored chunks for a stream as individual chunk bodies in order,
952
- * unpacking packed segment rows. The returned `chunk_index` is a running
953
- * per-chunk sequence (0, 1, 2, …) — stable across calls because rows are
954
- * append-only — so callers can use it as a monotonic chunk sequence.
1101
+ * unpacking packed segments. The returned `chunk_index` is a running
1102
+ * per-chunk sequence (0, 1, 2, …) — stable across calls because segments
1103
+ * are append-only — so callers can use it as a monotonic chunk sequence.
955
1104
  */
956
1105
  getStreamChunks(streamId) {
957
- const rows = this.sql`
958
- select body from cf_ai_chat_stream_chunks
959
- where stream_id = ${streamId}
960
- order by chunk_index asc
961
- ` || [];
962
- const out = [];
963
- let index = 0;
964
- for (const row of rows) for (const body of unpackSegmentBody(row.body)) {
965
- out.push({
966
- body,
967
- chunk_index: index
968
- });
969
- index++;
970
- }
971
- return out;
1106
+ return [...this._storedBodies(streamId)].map((body, chunk_index) => ({
1107
+ body,
1108
+ chunk_index
1109
+ }));
972
1110
  }
973
1111
  /** @internal For testing only */
974
1112
  getStreamMetadata(streamId) {
975
- const result = this.sql`
976
- select status, request_id from cf_ai_chat_stream_metadata
977
- where id = ${streamId}
978
- `;
979
- return result && result.length > 0 ? result[0] : null;
1113
+ const row = this.ops.getStream(streamId);
1114
+ if (!row || !parseChatMetadata(row)) return null;
1115
+ return {
1116
+ status: toPublicStatus(row.state),
1117
+ request_id: row.tag ?? ""
1118
+ };
980
1119
  }
981
1120
  /** @internal For testing only */
982
1121
  getAllStreamMetadata() {
983
- return this.sql`select id, status, request_id, created_at from cf_ai_chat_stream_metadata` || [];
1122
+ return this._chatRows().map((row) => ({
1123
+ id: row.stream_id,
1124
+ status: toPublicStatus(row.state),
1125
+ request_id: row.tag ?? "",
1126
+ created_at: row.created_at,
1127
+ message_id: row.chat.messageId ?? null
1128
+ }));
984
1129
  }
985
1130
  /** @internal For testing only */
986
1131
  insertStaleStream(streamId, requestId, ageMs) {
987
1132
  const createdAt = Date.now() - ageMs;
988
- this.sql`
989
- insert into cf_ai_chat_stream_metadata (id, request_id, status, created_at)
990
- values (${streamId}, ${requestId}, 'streaming', ${createdAt})
991
- `;
1133
+ this.ops.importStream({
1134
+ streamId,
1135
+ state: "streaming",
1136
+ tag: requestId,
1137
+ metadata: { cfChat: 1 },
1138
+ chunkCount: 0,
1139
+ createdAt,
1140
+ updatedAt: createdAt,
1141
+ closedAt: null
1142
+ });
992
1143
  }
993
1144
  /**
994
- * Append a chunk to a stream dated `ageMs` in the past. Used to exercise the
995
- * last-activity sweep threshold: a long-running streaming row with a *recent*
996
- * chunk must survive even when its start time is older than the cutoff.
1145
+ * Append a chunk to a stream dated `ageMs` in the past. Used to exercise
1146
+ * reclaim's phase-2 verification: a long-running streaming row with a
1147
+ * *recent* chunk must survive even when its row `updated_at` (stamped at
1148
+ * open, not per append) is older than the coarse cutoff.
997
1149
  * @internal For testing only
998
1150
  */
999
1151
  insertChunkAt(streamId, body, ageMs) {
1000
- const createdAt = Date.now() - ageMs;
1001
- this.sql`
1002
- insert into cf_ai_chat_stream_chunks (id, stream_id, body, chunk_index, created_at)
1003
- values (${nanoid()}, ${streamId}, ${body}, 0, ${createdAt})
1004
- `;
1152
+ this.ops.importChunk(streamId, body, Date.now() - ageMs);
1005
1153
  }
1006
1154
  };
1007
- ResumableStream.CHUNK_MAX_BYTES = 18e5;
1155
+ //#endregion
1156
+ //#region src/chat/turn-task.ts
1157
+ /** Durable snapshot key for one chat turn's stash envelope. */
1158
+ function turnSnapshotKey(runId) {
1159
+ return `__cf_chat_turn_snapshot:${runId}`;
1160
+ }
1161
+ /**
1162
+ * Build the chat-turn Task handler for one host. Registered under the
1163
+ * host's `CHAT_FIBER_NAME` via `Tasks#register`.
1164
+ */
1165
+ function createChatTurnTaskDefinition(hooks) {
1166
+ return async (input, step) => {
1167
+ const { requestId, nonce } = input;
1168
+ await step.do("model-turn", {
1169
+ retries: { limit: 1 },
1170
+ timeout: "1 day"
1171
+ }, async ({ signal }) => {
1172
+ const runId = `chat_${nonce}`;
1173
+ const snapshotKey = turnSnapshotKey(runId);
1174
+ const entry = hooks.getLiveClosure(nonce);
1175
+ if (!entry) {
1176
+ const persisted = await hooks.storage.get(snapshotKey);
1177
+ const createdAt = await hooks.getRunCreatedAt(runId) ?? Date.now();
1178
+ await hooks.handleRecovery({
1179
+ id: runId,
1180
+ name: `${hooks.definitionName}:${requestId}`,
1181
+ snapshot: persisted ?? null,
1182
+ createdAt,
1183
+ recoveryReason: "interrupted"
1184
+ });
1185
+ await hooks.storage.delete(snapshotKey);
1186
+ return;
1187
+ }
1188
+ await hooks.storage.put(snapshotKey, entry.initial);
1189
+ try {
1190
+ const value = await hooks.keepAliveWhile(() => hooks.withStash({
1191
+ id: nonce,
1192
+ signal,
1193
+ stash: (data) => void hooks.storage.put(snapshotKey, entry.wrap(data)).catch(() => {})
1194
+ }, () => entry.run()));
1195
+ entry.settle.resolve(value);
1196
+ } catch (error) {
1197
+ entry.settle.reject(error);
1198
+ throw error;
1199
+ } finally {
1200
+ hooks.storage.delete(snapshotKey).catch(() => {});
1201
+ }
1202
+ });
1203
+ };
1204
+ }
1205
+ //#endregion
1206
+ //#region src/chat/recovery-task.ts
1008
1207
  /**
1009
- * The buffer-cleanup alarm body: sweep aged stream buffers, then re-arm only
1010
- * while rows remain so a fully-swept DO stops waking itself. `rearm` schedules
1011
- * the next sweep — it MUST schedule a non-idempotent alarm, because this runs
1012
- * INSIDE the currently-executing one-shot schedule row, which `alarm()` deletes
1013
- * only after it returns; an idempotent reschedule would dedup onto that row and
1014
- * be deleted with it, so the re-arm would silently never fire and buffers that
1015
- * survived this sweep (e.g. a younger turn) would go uncollected. A fresh
1016
- * delayed row survives the deletion. Shared by `AIChatAgent` and `Think`.
1208
+ * Chat recovery transport on the Tasks capability.
1209
+ *
1210
+ * Each continuation attempt is one short Task run. The Task waits for any
1211
+ * requested backoff, dispatches the bounded host callback through model
1212
+ * handoff, then disappears after terminal settlement. The durable recovery
1213
+ * incident remains the source of truth for attempt and work budgets.
1017
1214
  *
1018
- * `@internal`
1215
+ * @internal Sibling-package support for AI Chat and Think.
1019
1216
  */
1020
- async function cleanupStreamBuffers(stream, rearm) {
1021
- stream.cleanup();
1022
- if (stream.hasReclaimableStreams()) await rearm();
1217
+ /** Reserved Task definition shared by the chat hosts. */
1218
+ const CHAT_RECOVERY_TASK_NAME = "__cf_internal_chat_recovery";
1219
+ /**
1220
+ * Run a queue-driven recovery callback up to its model handoff, then return.
1221
+ *
1222
+ * The recovered turn can legitimately run for a long time, and awaiting it
1223
+ * would hold the Lifecycle job loop, starving every other job on the object.
1224
+ * A failure before the handoff rejects here, so the executing Task run (or
1225
+ * compatibility schedule row) keeps ownership and the driver's
1226
+ * platform-failure deferral applies (#1730). After the handoff the turn is
1227
+ * detached alarm work: a platform failure enqueues one replacement attempt,
1228
+ * retried a few times since the completed Task no longer owns this incident
1229
+ * and a failure here would otherwise abandon it silently, and any other
1230
+ * failure belongs to the turn's own incident bookkeeping.
1231
+ */
1232
+ async function dispatchChatRecoveryToHandoff(handoff) {
1233
+ let handedOff = false;
1234
+ let signalHandoff = () => {};
1235
+ const reachedTurn = new Promise((resolve) => {
1236
+ signalHandoff = () => {
1237
+ handedOff = true;
1238
+ resolve();
1239
+ };
1240
+ });
1241
+ const turn = handoff.detached(signalHandoff);
1242
+ const tracked = reachedTurn.then(() => handoff.track(turn));
1243
+ turn.catch((error) => {
1244
+ if (!handedOff) return;
1245
+ if (isPlatformFailure(error)) {
1246
+ const dedupeKey = crypto.randomUUID();
1247
+ tryN(3, () => handoff.redefer(dedupeKey), {
1248
+ baseDelayMs: 50,
1249
+ maxDelayMs: 500
1250
+ }).catch((redeferError) => handoff.onDetachedError(redeferError));
1251
+ return;
1252
+ }
1253
+ handoff.onDetachedError(error);
1254
+ });
1255
+ await Promise.race([turn, tracked]);
1256
+ }
1257
+ function isRecord(value) {
1258
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1259
+ }
1260
+ function parseChatRecoveryTaskInput(input) {
1261
+ if (!isRecord(input)) throw new Error("Chat recovery Task input must be an object");
1262
+ const callback = input.callback;
1263
+ if (callback !== "_chatRecoveryContinue" && callback !== "_chatRecoveryRetry") throw new Error("Chat recovery Task input has an unknown callback");
1264
+ if (!isRecord(input.data)) throw new Error("Chat recovery Task input data must be an object");
1265
+ const delaySeconds = input.delaySeconds;
1266
+ if (typeof delaySeconds !== "number" || !Number.isFinite(delaySeconds) || delaySeconds < 0) throw new Error("Chat recovery Task delaySeconds must be a finite non-negative number");
1267
+ return {
1268
+ callback,
1269
+ data: input.data,
1270
+ delaySeconds
1271
+ };
1272
+ }
1273
+ /**
1274
+ * Build run options for one recovery attempt.
1275
+ *
1276
+ * Initial detection joins an existing in-flight attempt for the same incident
1277
+ * and callback. Chained retries are otherwise unkeyed because each one is
1278
+ * enqueued while the preceding run still exists — a genuinely new attempt,
1279
+ * not a retry of this same enqueue. `dedupeKey`, when supplied, keys this
1280
+ * specific enqueue call by `runId` instead: every retry of one failed
1281
+ * `redefer` (see {@link dispatchChatRecoveryToHandoff}) reuses the same key,
1282
+ * so a rejected-but-already-inserted attempt is joined rather than
1283
+ * duplicated. Non-retention releases the initial key when the run settles.
1284
+ */
1285
+ function chatRecoveryTaskRunOptions(input, reason, dedupeKey) {
1286
+ const incidentId = typeof input.data.incidentId === "string" ? input.data.incidentId : void 0;
1287
+ const recoveredRequestId = typeof input.data.recoveredRequestId === "string" ? input.data.recoveredRequestId : void 0;
1288
+ return {
1289
+ retain: false,
1290
+ ...dedupeKey !== void 0 ? { runId: dedupeKey } : {},
1291
+ ...reason === "initial" && incidentId ? { idempotencyKey: `chat-recovery:${input.callback}:${incidentId}` } : {},
1292
+ metadata: {
1293
+ callback: input.callback,
1294
+ ...incidentId ? { incidentId } : {},
1295
+ ...recoveredRequestId ? { recoveredRequestId } : {}
1296
+ }
1297
+ };
1298
+ }
1299
+ /** Build the shared recovery Task handler for one chat host. */
1300
+ function createChatRecoveryTaskDefinition(hooks) {
1301
+ return async (unknownInput, step) => {
1302
+ const input = parseChatRecoveryTaskInput(unknownInput);
1303
+ if (input.delaySeconds > 0) await step.sleep("backoff", input.delaySeconds * 1e3);
1304
+ await step.do("continuation", {
1305
+ retries: {
1306
+ limit: 3,
1307
+ delay: 100,
1308
+ backoff: "exponential"
1309
+ },
1310
+ timeout: "15 minutes"
1311
+ }, () => hooks[input.callback](input.data));
1312
+ };
1023
1313
  }
1024
1314
  //#endregion
1025
1315
  //#region src/chat/sql-batch.ts
@@ -2293,11 +2583,12 @@ function partialHasSettledToolResults(parts) {
2293
2583
  });
2294
2584
  }
2295
2585
  /**
2296
- * The single, host-agnostic rule for crediting recovery forward progress from a
2297
- * stored stream chunk — the convergence of what `AIChatAgent` and `Think`
2298
- * previously each decided on their own (ai-chat keyed on chunk type only; Think
2299
- * keyed on its flush cadence). Both hosts now call this at chunk-store time so
2300
- * the bump TIMING is identical:
2586
+ * The host-agnostic rule for crediting recovery forward progress from a stored
2587
+ * stream chunk, from when the marker was a counter bumped per chunk. Neither
2588
+ * host bumps a counter any more: the marker is derived from the stream log
2589
+ * (`ResumableStream.progressMarker`), so a chunk counts once its segment is
2590
+ * durably flushed. The rule stays exported for consumers that still keep a
2591
+ * counter of their own:
2301
2592
  *
2302
2593
  * - a **milestone** ({@link ChatRecoveryCodec.isProgressChunk}) always credits;
2303
2594
  * - **streaming content** ({@link ChatRecoveryCodec.isStreamingContentChunk})
@@ -2464,10 +2755,14 @@ var ResumeHandshake = class {
2464
2755
  //#region src/chat/recovery-incident.ts
2465
2756
  const CHAT_RECOVERY_INCIDENT_KEY_PREFIX = "cf:chat-recovery:incident:";
2466
2757
  /**
2467
- * Durable, monotonic forward-progress counter for recovery budget resets.
2468
- * Bumped at production time when new content is streamed, so it reflects
2469
- * genuinely new content and is immune to reconnects/re-persists; never
2470
- * recomputed from the (compactable) transcript.
2758
+ * The pre-derivation forward-progress counter: a KV integer bumped per
2759
+ * credited chunk. The marker is now derived from the stream log
2760
+ * (`ResumableStream.progressMarker`), and this key is only read — once per
2761
+ * isolate, to seed the derived marker so it never reads lower than the
2762
+ * high-water mark an incident recorded before the upgrade. Current code
2763
+ * writes it only as a mirror of the derived marker's durable part — one put
2764
+ * per stream retired, none per chunk — so a build rolled back to the
2765
+ * counter never reads a marker lower than an incident recorded here.
2471
2766
  */
2472
2767
  const CHAT_RECOVERY_PROGRESS_KEY = "cf:chat-recovery:progress";
2473
2768
  /**
@@ -2511,8 +2806,17 @@ const DEFAULT_CHAT_RECOVERY_MAX_ATTEMPTS = 10;
2511
2806
  * the incident is deleted). A very long agentic turn under heavy interruption
2512
2807
  * that legitimately needs more should raise `maxRecoveryWork` (or set it to
2513
2808
  * `Infinity` to restore the pre-#1825 unbounded behavior).
2809
+ *
2810
+ * The unit is one durable stream segment — about ten packed streaming chunks,
2811
+ * or one settled tool result, which is flushed on its own — plus one per
2812
+ * explicit credit for forwarded sub-agent output. The marker is derived from
2813
+ * the stream log (`ResumableStream.progressMarker`), so that is what it can
2814
+ * count. The earlier KV counter credited per milestone chunk and per five
2815
+ * seconds of deltas, a coarser measure of streamed text; 10 000 segments
2816
+ * (on the order of 100 000 chunks of re-run output) keeps the budget as
2817
+ * generous as 1 000 credits was for delta-heavy turns, and still finite.
2514
2818
  */
2515
- const DEFAULT_CHAT_RECOVERY_MAX_WORK = 1e3;
2819
+ const DEFAULT_CHAT_RECOVERY_MAX_WORK = 1e4;
2516
2820
  /**
2517
2821
  * Tight, OOM-specific retry budget (#1825). A Durable Object memory-limit reset
2518
2822
  * (`isDurableObjectMemoryLimitReset`) is usually deterministic — the turn's
@@ -2655,9 +2959,13 @@ async function readChatRecoveryProgress(storage) {
2655
2959
  return await storage.get("cf:chat-recovery:progress") ?? 0;
2656
2960
  }
2657
2961
  /**
2658
- * Advance the durable recovery-progress counter by one. Called when genuinely new
2659
- * content is durably flushed (real, reconnect-immune forward progress); shared by
2660
- * `AIChatAgent` and `Think`.
2962
+ * Advance the KV progress counter by one.
2963
+ *
2964
+ * @deprecated Hosts no longer bump a counter per credited chunk: the marker
2965
+ * is derived from the stream log (`ResumableStream.progressMarker`) and
2966
+ * explicit credits go through `ResumableStream.creditProgress`. Kept for
2967
+ * code that still maintains the KV counter; a value written here is folded
2968
+ * into the derived marker on the next seed.
2661
2969
  */
2662
2970
  async function bumpChatRecoveryProgress(storage) {
2663
2971
  const current = await storage.get("cf:chat-recovery:progress") ?? 0;
@@ -2880,18 +3188,6 @@ async function evaluateChatRecoveryIncident(input) {
2880
3188
  //#endregion
2881
3189
  //#region src/chat/recovery-engine.ts
2882
3190
  /**
2883
- * Resolve the `schedule()` idempotency option for a recovery schedule. Single
2884
- * source of truth for both packages; see {@link ChatRecoveryScheduleReason} for
2885
- * the rationale behind each case.
2886
- *
2887
- * This is a cutover invariant: flipping either case silently breaks deploy-storm
2888
- * dedup (initial) or stalls stable-timeout retries (reschedule), and neither is
2889
- * caught by a type error — only by the recovery suites.
2890
- */
2891
- function chatRecoverySchedulePolicy(reason) {
2892
- return { idempotent: reason === "initial" };
2893
- }
2894
- /**
2895
3191
  * Drives the shared recovery orchestration over a {@link ChatRecoveryAdapter}.
2896
3192
  * The incident *budget math* lives in the pure `evaluateChatRecoveryIncident`;
2897
3193
  * this class owns the surrounding sequence and its ordering invariants.
@@ -3069,7 +3365,8 @@ var ChatRecoveryEngine = class {
3069
3365
  * 1. transition the incident to `scheduled` (persist + drive the #1620
3070
3366
  * "recovering…" status) via {@link updateIncident};
3071
3367
  * 2. emit `chat:recovery:scheduled`; and
3072
- * 3. enqueue the callback through the adapter's idempotent schedule.
3368
+ * 3. enqueue the callback through the adapter's transport — a Task run on
3369
+ * a root agent, an idempotent schedule on the routed fallback.
3073
3370
  *
3074
3371
  * `recoveryKind` is passed explicitly (not read off the incident) because a
3075
3372
  * caller can legitimately report a different kind than the incident was opened
@@ -3093,12 +3390,14 @@ var ChatRecoveryEngine = class {
3093
3390
  }
3094
3391
  /**
3095
3392
  * Reschedule a recovery continuation/retry that timed out waiting for stable
3096
- * state, INSIDE the currently-executing one-shot schedule row. Reads the
3393
+ * state, from INSIDE the currently-executing recovery attempt (a
3394
+ * `__cf_internal_chat_recovery` Task run for a root agent, the routed
3395
+ * one-shot schedule row for a dynamic agent). Reads the
3097
3396
  * incident; if it is still under the attempt cap, bumps `attempt`, marks it
3098
- * `scheduled` with `reason:"stable_timeout_retry"`, and issues a delayed,
3099
- * NON-idempotent schedule (`alarm()` deletes the executing row only after this
3100
- * returns, so an idempotent reschedule would dedup onto that doomed row and
3101
- * never fire — see {@link chatRecoverySchedulePolicy}).
3397
+ * `scheduled` with `reason:"stable_timeout_retry"`, and issues a separate
3398
+ * delayed attempt. It must not join the currently executing attempt: Tasks
3399
+ * enqueue an unkeyed chained run, while the routed fallback creates a
3400
+ * non-idempotent schedule.
3102
3401
  *
3103
3402
  * Returns `true` when a retry was scheduled, `false` when there is no incident
3104
3403
  * (no id / record gone) or the attempt budget is already spent — in which case
@@ -3133,9 +3432,9 @@ var ChatRecoveryEngine = class {
3133
3432
  * Bumps the incident's durable `oomAttempts` counter, then:
3134
3433
  * - if it is still within `maxOomRetries`, issues a delayed, NON-idempotent
3135
3434
  * reschedule of the SAME callback (same machinery as
3136
- * {@link rescheduleAfterStableTimeout}: the executing one-shot row is
3137
- * deleted only after the callback returns, so an idempotent reschedule
3138
- * would dedup onto that doomed row) and returns `"rescheduled"`. The small
3435
+ * {@link rescheduleAfterStableTimeout}: the executing attempt settles
3436
+ * only after the callback returns, so an idempotent reschedule would
3437
+ * dedup onto that doomed attempt) and returns `"rescheduled"`. The small
3139
3438
  * delay lets a transient memory spike clear before the re-run;
3140
3439
  * - otherwise leaves the incremented count persisted (so a begin-path
3141
3440
  * re-evaluation agrees) and returns `"exhausted"` — the caller then
@@ -3192,8 +3491,9 @@ var ChatRecoveryEngine = class {
3192
3491
  * 6. terminalize via `exhaustChatRecovery` — BEFORE sealing. The terminal
3193
3492
  * writes can reject with a platform transient in the deploy/storage window
3194
3493
  * a give-up runs in (#1730); letting that throw propagate is deliberate, so
3195
- * `Agent._executeScheduleCallback` defers the one-shot row and the WHOLE
3196
- * give-up re-runs on a healthy isolate. Sealing first would arm the
3494
+ * the current recovery attempt (a Task run on a root agent, a one-shot
3495
+ * schedule row on the routed fallback) defers and the WHOLE give-up
3496
+ * re-runs on a healthy isolate. Sealing first would arm the
3197
3497
  * re-entry guard and turn that re-run into a no-op, dropping the durable
3198
3498
  * terminal record. The re-run is idempotent (terminal writes overwrite the
3199
3499
  * same key); a second banner is the documented at-least-once edge; and
@@ -3448,6 +3748,80 @@ async function* iterateWithStallWatchdog(source, timeoutMs, onStall) {
3448
3748
  }
3449
3749
  }
3450
3750
  //#endregion
3451
- export { AGENT_TOOL_STREAM_PROGRESS_BUMP_THROTTLE_MS, AbortRegistry, AgentToolProgressEmitter, AgentToolStreamProgressThrottle, AutoContinuationController, CHAT_LAST_TERMINAL_KEY, CHAT_MESSAGE_TYPES, CHAT_RECOVERING_FLAG_TTL_MS, CHAT_RECOVERING_KEY, CHAT_RECOVERY_ALARM_DEBOUNCE_MS, CHAT_RECOVERY_INCIDENT_KEY_PREFIX, CHAT_RECOVERY_INCIDENT_TTL_MS, CHAT_RECOVERY_PROGRESS_KEY, CHAT_RECOVERY_STABLE_RETRY_DELAY_SECONDS, CHAT_STREAM_PROGRESS_CREDIT_THROTTLE_MS, ChatRecoveryEngine, ChatStreamStalledError, ContinuationState, DEFAULT_CHAT_RECOVERY_MAX_ATTEMPTS, DEFAULT_CHAT_RECOVERY_MAX_OOM_RETRIES, DEFAULT_CHAT_RECOVERY_MAX_WORK, DEFAULT_CHAT_RECOVERY_NO_PROGRESS_TIMEOUT_MS, DEFAULT_CHAT_RECOVERY_STABLE_TIMEOUT_MS, DEFAULT_CHAT_RECOVERY_TERMINAL_MESSAGE, KV_DELETE_MAX_KEYS, MAX_BOUND_PARAMS, MessageType, PreStreamTurns, ROW_MAX_BYTES, ResumableStream, ResumeHandshake, STREAM_CLEANUP_DELAY_SECONDS, STREAM_RESUME_NONE_REASONS, StreamAccumulator, StreamProgressCreditThrottle, SubmitConcurrencyController, TIMED_OUT, TextSegmentJoiner, TurnQueue, aiSdkRecoveryCodec, applyAgentToolEvent, applyChunkToParts, applyToolUpdate, awaitWithDeadline, transition as broadcastTransition, buildChatRecoveringFrame, buildInClauseStrings, bumpChatRecoveryProgress, byteLength, chatRecoverySchedulePolicy, classifyAgentToolChildRecovery, cleanupStreamBuffers, clearChatTerminal, clientResolvableToolNames, createAgentToolEventState, createChatFiberSnapshot, createToolsFromClientSchemas, crossMessageToolResultUpdate, drainInteractionApplies, enforceRowSizeLimit, hasIncompleteToolBatch, interceptAgentToolBroadcast, isReplayChunk, iterateWithStallWatchdog, listActiveChatRecoveryIncidents, normalizeToolInput, parseProtocolMessage, partAwaitsClientInteraction, pausedExecutionUpdate, pendingChatTerminal, persistReconstructedOrphan, readChatRecoveryProgress, reconcileMessages, reconcileOrphanPartial, recordChatTerminal, repairInterruptedToolParts, resolveChatRecoveryConfig, resolveToolMergeId, runChatRecoveryExhaustion, sanitizeMessage, sendIfOpen, setChatRecovering, shouldCreditStreamProgress, sweepStaleChatRecoveryIncidents, toolApprovalUpdate, toolPartHasSettledResult, toolResultUpdate, unwrapChatFiberSnapshot, wrapChatFiberSnapshot };
3751
+ //#region src/chat/truncate-older-messages.ts
3752
+ /**
3753
+ * Read-time context truncation.
3754
+ *
3755
+ * Truncates older tool outputs and long text before sending to the LLM.
3756
+ * Structured tool outputs keep their container shape so tool-specific
3757
+ * `toModelOutput` handlers can safely replay older results.
3758
+ * Does NOT mutate stored messages — operates on a copy.
3759
+ */
3760
+ /**
3761
+ * Truncate tool outputs and long text in older messages.
3762
+ * Returns a new array — input messages are not mutated.
3763
+ *
3764
+ * Recent messages (last `keepRecent`) are left intact.
3765
+ * Older messages get tool outputs and long text truncated. Structured tool
3766
+ * outputs are truncated in place instead of being replaced by raw strings.
3767
+ *
3768
+ * Use in assembleContext() before sending to the LLM:
3769
+ * ```typescript
3770
+ * async assembleContext() {
3771
+ * const history = this.sessions.getHistory(this._sessionId);
3772
+ * const truncated = truncateOlderMessages(history);
3773
+ * return convertToModelMessages(truncated);
3774
+ * }
3775
+ * ```
3776
+ */
3777
+ function truncateOlderMessages(messages, options) {
3778
+ const keepRecent = options?.keepRecent ?? 4;
3779
+ const maxToolOutput = options?.maxToolOutputChars ?? 500;
3780
+ const maxText = options?.maxTextChars ?? 1e4;
3781
+ if (messages.length <= keepRecent) return messages;
3782
+ const cutoff = messages.length - keepRecent;
3783
+ const result = [];
3784
+ for (let i = 0; i < messages.length; i++) {
3785
+ if (i >= cutoff) {
3786
+ result.push(messages[i]);
3787
+ continue;
3788
+ }
3789
+ const msg = messages[i];
3790
+ let changed = false;
3791
+ const truncatedParts = msg.parts.map((part) => {
3792
+ if ((part.type.startsWith("tool-") || part.type === "dynamic-tool") && "output" in part) {
3793
+ const output = part.output;
3794
+ if (output !== void 0) {
3795
+ const truncated = truncateToolOutput(output, maxToolOutput);
3796
+ if (truncated.truncated) {
3797
+ changed = true;
3798
+ return {
3799
+ ...part,
3800
+ output: truncated.output
3801
+ };
3802
+ }
3803
+ }
3804
+ }
3805
+ if (part.type === "text" && "text" in part) {
3806
+ const text = part.text;
3807
+ if (text.length > maxText) {
3808
+ changed = true;
3809
+ return {
3810
+ ...part,
3811
+ text: `${text.slice(0, maxText)}... [truncated ${text.length} chars]`
3812
+ };
3813
+ }
3814
+ }
3815
+ return part;
3816
+ });
3817
+ result.push(changed ? {
3818
+ ...msg,
3819
+ parts: truncatedParts
3820
+ } : msg);
3821
+ }
3822
+ return result;
3823
+ }
3824
+ //#endregion
3825
+ export { AGENT_TOOL_STREAM_PROGRESS_BUMP_THROTTLE_MS, AbortRegistry, AgentToolProgressEmitter, AgentToolStreamProgressThrottle, AutoContinuationController, CHAT_LAST_TERMINAL_KEY, CHAT_MESSAGE_TYPES, CHAT_RECOVERING_FLAG_TTL_MS, CHAT_RECOVERING_KEY, CHAT_RECOVERY_ALARM_DEBOUNCE_MS, CHAT_RECOVERY_INCIDENT_KEY_PREFIX, CHAT_RECOVERY_INCIDENT_TTL_MS, CHAT_RECOVERY_PROGRESS_KEY, CHAT_RECOVERY_STABLE_RETRY_DELAY_SECONDS, CHAT_RECOVERY_TASK_NAME, CHAT_STREAM_PROGRESS_CREDIT_THROTTLE_MS, ChatRecoveryEngine, ChatStreamStalledError, ContinuationState, DEFAULT_CHAT_RECOVERY_MAX_ATTEMPTS, DEFAULT_CHAT_RECOVERY_MAX_OOM_RETRIES, DEFAULT_CHAT_RECOVERY_MAX_WORK, DEFAULT_CHAT_RECOVERY_NO_PROGRESS_TIMEOUT_MS, DEFAULT_CHAT_RECOVERY_STABLE_TIMEOUT_MS, DEFAULT_CHAT_RECOVERY_TERMINAL_MESSAGE, KV_DELETE_MAX_KEYS, MAX_BOUND_PARAMS, MessageType, PreStreamTurns, ROW_MAX_BYTES, ResumableStream, ResumeHandshake, STREAM_RESUME_NONE_REASONS, StreamAccumulator, StreamProgressCreditThrottle, SubmitConcurrencyController, TIMED_OUT, TextSegmentJoiner, TurnQueue, aiSdkRecoveryCodec, applyAgentToolEvent, applyChunkToParts, applyToolUpdate, awaitWithDeadline, transition as broadcastTransition, buildChatRecoveringFrame, buildInClauseStrings, bumpChatRecoveryProgress, byteLength, chatRecoveryTaskRunOptions, classifyAgentToolChildRecovery, clearChatTerminal, clientResolvableToolNames, createAgentToolEventState, createChatFiberSnapshot, createChatRecoveryTaskDefinition, createChatStreams, createChatTurnTaskDefinition, createToolsFromClientSchemas, crossMessageToolResultUpdate, dispatchChatRecoveryToHandoff, drainInteractionApplies, enforceRowSizeLimit, hasIncompleteToolBatch, interceptAgentToolBroadcast, isPlatformFailure, isReplayChunk, iterateWithStallWatchdog, listActiveChatRecoveryIncidents, normalizeToolInput, parseProtocolMessage, partAwaitsClientInteraction, pausedExecutionUpdate, pendingChatTerminal, persistReconstructedOrphan, readChatRecoveryProgress, reconcileMessages, reconcileOrphanPartial, recordChatTerminal, repairInterruptedToolParts, resolveChatRecoveryConfig, resolveToolMergeId, runChatRecoveryExhaustion, sanitizeMessage, sendIfOpen, setChatRecovering, shouldCreditStreamProgress, sweepStaleChatRecoveryIncidents, toolApprovalUpdate, toolPartHasSettledResult, toolResultUpdate, truncateOlderMessages, unwrapChatFiberSnapshot, wrapChatFiberSnapshot };
3452
3826
 
3453
3827
  //# sourceMappingURL=index.js.map