agents 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -1
- package/dist/{agent-tool-types-CzGGB-20.d.ts → agent-routing-DE5zmCQ8.d.ts} +1844 -1336
- package/dist/agent-routing.d.ts +14 -0
- package/dist/agent-routing.js +187 -0
- package/dist/agent-routing.js.map +1 -0
- package/dist/agent-tool-types.d.ts +26 -26
- package/dist/{agent-tools-zR2d5uij.d.ts → agent-tools-DtXMTDGM.d.ts} +7 -7
- package/dist/agent-tools.d.ts +21 -21
- package/dist/agent-tools.js +2 -1
- package/dist/agent-tools.js.map +1 -1
- package/dist/browser/ai.js +6 -2
- package/dist/browser/ai.js.map +1 -1
- package/dist/browser/tanstack-ai.js.map +1 -1
- package/dist/callable-decorator-DP__HhBA.d.ts +72 -0
- package/dist/callable-decorator.d.ts +18 -0
- package/dist/callable-decorator.js +71 -0
- package/dist/callable-decorator.js.map +1 -0
- package/dist/capability-BjSKYpzg.js +42 -0
- package/dist/capability-BjSKYpzg.js.map +1 -0
- package/dist/capability-runner-Be_-PLR1.d.ts +459 -0
- package/dist/channel-Bnm4S7T2.d.ts +491 -0
- package/dist/channels/ai-sdk.d.ts +46 -0
- package/dist/channels/ai-sdk.js +120 -0
- package/dist/channels/ai-sdk.js.map +1 -0
- package/dist/channels/email.d.ts +95 -0
- package/dist/channels/email.js +323 -0
- package/dist/channels/email.js.map +1 -0
- package/dist/channels/index.d.ts +233 -0
- package/dist/channels/index.js +608 -0
- package/dist/channels/index.js.map +1 -0
- package/dist/channels/slack.d.ts +140 -0
- package/dist/channels/slack.js +614 -0
- package/dist/channels/slack.js.map +1 -0
- package/dist/channels/tanstack-ai.d.ts +39 -0
- package/dist/channels/tanstack-ai.js +17 -0
- package/dist/channels/tanstack-ai.js.map +1 -0
- package/dist/channels/telegram.d.ts +106 -0
- package/dist/channels/telegram.js +427 -0
- package/dist/channels/telegram.js.map +1 -0
- package/dist/channels/voice.d.ts +45 -0
- package/dist/channels/voice.js +122 -0
- package/dist/channels/voice.js.map +1 -0
- package/dist/chat/index.d.ts +2328 -2015
- package/dist/chat/index.js +891 -521
- package/dist/chat/index.js.map +1 -1
- package/dist/chat/react.d.ts +14 -1
- package/dist/chat/react.js +82 -52
- package/dist/chat/react.js.map +1 -1
- package/dist/chat/transport.js +1 -1
- package/dist/chat-sdk/index.d.ts +7 -7
- package/dist/chat-sdk/index.js +1 -1
- package/dist/{client-zqKcsyFa.js → client-jagG8a9_.js} +129 -37
- package/dist/client-jagG8a9_.js.map +1 -0
- package/dist/client.d.ts +1 -1
- package/dist/client.js +1 -1
- package/dist/{cloudflare-BduZwmYK.js → cloudflare-Dzvc7V2N.js} +10 -3
- package/dist/{cloudflare-BduZwmYK.js.map → cloudflare-Dzvc7V2N.js.map} +1 -1
- package/dist/context/index.d.ts +216 -0
- package/dist/context/index.js +454 -0
- package/dist/context/index.js.map +1 -0
- package/dist/current-agent-Da_C9a3b.d.ts +266 -0
- package/dist/current-agent-DhoDkSnH.js +51 -0
- package/dist/current-agent-DhoDkSnH.js.map +1 -0
- package/dist/diagnostics-BzvaX2UT.js +45 -0
- package/dist/diagnostics-BzvaX2UT.js.map +1 -0
- package/dist/diagnostics-C4jcz3VK.js +360 -0
- package/dist/diagnostics-C4jcz3VK.js.map +1 -0
- package/dist/{do-oauth-client-provider-VTZj2VtM.d.ts → do-oauth-client-provider-Tmf1vgKz.d.ts} +2 -2
- package/dist/{email-CL27preh.d.ts → email-7TatiTnl.d.ts} +38 -9
- package/dist/email-send.d.ts +15 -0
- package/dist/email-send.js +32 -0
- package/dist/email-send.js.map +1 -0
- package/dist/email.d.ts +14 -10
- package/dist/email.js.map +1 -1
- package/dist/{handler-stateless-C_bo-Ytq.d.ts → handler-stateless-DxYpJ_XF.d.ts} +3 -3
- package/dist/{handler-stateless-CIkKPETH.js → handler-stateless-VvrWSAVA.js} +5 -5
- package/dist/handler-stateless-VvrWSAVA.js.map +1 -0
- package/dist/index-BB0kqhIz.d.ts +101 -0
- package/dist/index-XDkuQ7zm.d.ts +89 -0
- package/dist/{index-BRnybD6X.d.ts → index-YSKgfgg9.d.ts} +21 -31
- package/dist/index.d.ts +113 -101
- package/dist/index.js +11 -7234
- package/dist/ingress-BfetZbMO.js +83 -0
- package/dist/ingress-BfetZbMO.js.map +1 -0
- package/dist/internal-CYlgHl1l.js +59 -0
- package/dist/internal-CYlgHl1l.js.map +1 -0
- package/dist/internal_context-BlxFEWfn.d.ts +19 -0
- package/dist/internal_context.d.ts +10 -4
- package/dist/internal_context.js +1 -10
- package/dist/{client-invoker-BNSZxAkv.d.ts → invoker-CG0_p_Wq.d.ts} +2 -2
- package/dist/{client-invoker-VNZ7X0nn.js → invoker-CHMnoxIA.js} +2 -2
- package/dist/invoker-CHMnoxIA.js.map +1 -0
- package/dist/lifecycle/index.d.ts +66 -0
- package/dist/lifecycle/index.js +4 -0
- package/dist/lifecycle-CMRGjZdw.js +1299 -0
- package/dist/lifecycle-CMRGjZdw.js.map +1 -0
- package/dist/mcp/{do-oauth-client-provider.d.ts → client/do-oauth-client-provider.d.ts} +1 -1
- package/dist/mcp/{do-oauth-client-provider.js → client/do-oauth-client-provider.js} +1 -1
- package/dist/mcp/client/do-oauth-client-provider.js.map +1 -0
- package/dist/mcp/client/index.d.ts +42 -0
- package/dist/mcp/{client.js → client/index.js} +1 -1
- package/dist/mcp/{x402.d.ts → client/x402.d.ts} +2 -2
- package/dist/mcp/{x402.js → client/x402.js} +2 -2
- package/dist/mcp/client/x402.js.map +1 -0
- package/dist/mcp/index.d.ts +36 -36
- package/dist/mcp/index.js +14 -16
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/{server.d.ts → server/index.d.ts} +1 -1
- package/dist/mcp/{server.js → server/index.js} +1 -1
- package/dist/observability/ai/index.js +50 -35
- package/dist/observability/ai/index.js.map +1 -1
- package/dist/observability/index.d.ts +4 -4
- package/dist/observability/index.js +3 -50
- package/dist/observability/index.js.map +1 -1
- package/dist/{protocol-Dqc2MQxo.js → protocol-B0nh6KNf.js} +19 -21
- package/dist/protocol-B0nh6KNf.js.map +1 -0
- package/dist/react.d.ts +4 -4
- package/dist/react.js +1 -1
- package/dist/{retries-CAvxtG9d.d.ts → retries-D9Ds-1lz.d.ts} +17 -6
- package/dist/retries.d.ts +8 -6
- package/dist/retries.js +13 -1
- package/dist/retries.js.map +1 -1
- package/dist/routing/index.d.ts +137 -0
- package/dist/routing/index.js +244 -0
- package/dist/routing/index.js.map +1 -0
- package/dist/sanitize-D9TujEK8.js +79 -0
- package/dist/sanitize-D9TujEK8.js.map +1 -0
- package/dist/schedule.d.ts +25 -94
- package/dist/schedule.js +1 -98
- package/dist/schedule.js.map +1 -1
- package/dist/scheduler-DD9NdYbF.js +665 -0
- package/dist/scheduler-DD9NdYbF.js.map +1 -0
- package/dist/scheduler-Dwh85ZGl.d.ts +223 -0
- package/dist/schedules/index.d.ts +22 -0
- package/dist/schedules/index.js +2 -0
- package/dist/schedules/parser.d.ts +79 -0
- package/dist/schedules/parser.js +103 -0
- package/dist/schedules/parser.js.map +1 -0
- package/dist/sentence-chunker-BAidJ4DA.d.ts +68 -0
- package/dist/serializable.d.ts +1 -1
- package/dist/sessions/index.d.ts +441 -0
- package/dist/sessions/index.js +2063 -0
- package/dist/sessions/index.js.map +1 -0
- package/dist/skills/index.d.ts +99 -0
- package/dist/skills/index.js +254 -5
- package/dist/skills/index.js.map +1 -1
- package/dist/sql-error-CPY-GXyI.d.ts +12 -0
- package/dist/sql-error.d.ts +2 -0
- package/dist/sql-error.js +16 -0
- package/dist/sql-error.js.map +1 -0
- package/dist/src-DlSHshb2.js +6963 -0
- package/dist/src-DlSHshb2.js.map +1 -0
- package/dist/streams/index.d.ts +120 -0
- package/dist/streams/index.js +107 -0
- package/dist/streams/index.js.map +1 -0
- package/dist/streams-D6tJ0NN9.d.ts +370 -0
- package/dist/streams-DZKgAj9b.js +709 -0
- package/dist/streams-DZKgAj9b.js.map +1 -0
- package/dist/sub-routing.d.ts +12 -12
- package/dist/surface-bZZJqBka.js +17 -0
- package/dist/surface-bZZJqBka.js.map +1 -0
- package/dist/tasks/index.d.ts +64 -0
- package/dist/tasks/index.js +2 -0
- package/dist/tasks-BRJ5zgya.d.ts +517 -0
- package/dist/tasks-ylZgBjhj.js +1656 -0
- package/dist/tasks-ylZgBjhj.js.map +1 -0
- package/dist/text-segment-joiner-BtAFQSA_.js +57 -0
- package/dist/text-segment-joiner-BtAFQSA_.js.map +1 -0
- package/dist/text-stream-CpdiKrJB.js +272 -0
- package/dist/text-stream-CpdiKrJB.js.map +1 -0
- package/dist/tokens-nHAKcN6M.js +52 -0
- package/dist/tokens-nHAKcN6M.js.map +1 -0
- package/dist/tool-schema-CBjGPrsQ.js +31 -0
- package/dist/tool-schema-CBjGPrsQ.js.map +1 -0
- package/dist/types-B7LojTe4.d.ts +202 -0
- package/dist/types-_Faxb570.d.ts +439 -0
- package/dist/voice/client.d.ts +226 -0
- package/dist/voice/client.js +932 -0
- package/dist/voice/client.js.map +1 -0
- package/dist/voice/errors.d.ts +43 -0
- package/dist/voice/errors.js +41 -0
- package/dist/voice/errors.js.map +1 -0
- package/dist/voice/index.d.ts +271 -0
- package/dist/voice/index.js +1812 -0
- package/dist/voice/index.js.map +1 -0
- package/dist/voice/react.d.ts +167 -0
- package/dist/voice/react.js +234 -0
- package/dist/voice/react.js.map +1 -0
- package/dist/voice/sfu.d.ts +71 -0
- package/dist/voice/sfu.js +157 -0
- package/dist/voice/sfu.js.map +1 -0
- package/dist/voice/text.d.ts +6 -0
- package/dist/voice/text.js +2 -0
- package/dist/voice/types.d.ts +58 -0
- package/dist/voice/types.js +18 -0
- package/dist/voice/types.js.map +1 -0
- package/dist/voice/workers-ai.d.ts +136 -0
- package/dist/voice/workers-ai.js +568 -0
- package/dist/voice/workers-ai.js.map +1 -0
- package/dist/websockets/index.d.ts +192 -0
- package/dist/websockets/index.js +2 -0
- package/dist/websockets-DUfRHPRq.js +502 -0
- package/dist/websockets-DUfRHPRq.js.map +1 -0
- package/dist/workflow-types.d.ts +25 -25
- package/dist/workflows.d.ts +22 -22
- package/dist/workflows.js +2 -1
- package/dist/workflows.js.map +1 -1
- package/dist/{ws-chat-transport-CIoOBbO7.js → ws-chat-transport-rWwta645.js} +152 -15
- package/dist/ws-chat-transport-rWwta645.js.map +1 -0
- package/docs/agent-class.md +29 -87
- package/docs/agent-tools.md +2 -1
- package/docs/channels.md +323 -0
- package/docs/chat-agents.md +19 -25
- package/docs/context.md +131 -0
- package/docs/durable-execution.md +1 -1
- package/docs/http-websockets.md +1 -11
- package/docs/human-in-the-loop.md +1 -1
- package/docs/index.md +16 -12
- package/docs/lifecycle.md +370 -0
- package/docs/long-running-agents.md +4 -6
- package/docs/mcp-client.md +55 -0
- package/docs/mcp-servers.md +5 -1
- package/docs/observability.md +11 -11
- package/docs/resumable-streaming.md +2 -2
- package/docs/routing.md +105 -0
- package/docs/scheduling.md +175 -15
- package/docs/server-driven-messages.md +1 -1
- package/docs/sessions.md +237 -871
- package/docs/streams.md +213 -0
- package/docs/sub-agents.md +185 -125
- package/docs/tasks.md +246 -0
- package/docs/voice.md +745 -0
- package/package.json +144 -33
- package/dist/cli/index.js +0 -26
- package/dist/cli/index.js.map +0 -1
- package/dist/client-invoker-VNZ7X0nn.js.map +0 -1
- package/dist/client-zqKcsyFa.js.map +0 -1
- package/dist/compaction-helpers-iiKMr2TQ.js +0 -340
- package/dist/compaction-helpers-iiKMr2TQ.js.map +0 -1
- package/dist/compaction-helpers-wUz6M3us.d.ts +0 -621
- package/dist/experimental/memory/session/index.d.ts +0 -670
- package/dist/experimental/memory/session/index.js +0 -2374
- package/dist/experimental/memory/session/index.js.map +0 -1
- package/dist/experimental/memory/utils/index.d.ts +0 -96
- package/dist/experimental/memory/utils/index.js +0 -79
- package/dist/experimental/memory/utils/index.js.map +0 -1
- package/dist/handler-stateless-CIkKPETH.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/internal_context-Dg4Cgjcu.d.ts +0 -37
- package/dist/internal_context.js.map +0 -1
- package/dist/mcp/client.d.ts +0 -42
- package/dist/mcp/do-oauth-client-provider.js.map +0 -1
- package/dist/mcp/x402.js.map +0 -1
- package/dist/protocol-Dqc2MQxo.js.map +0 -1
- package/dist/tool-output-truncation-CNnnGZQ3.js +0 -98
- package/dist/tool-output-truncation-CNnnGZQ3.js.map +0 -1
- package/dist/ws-chat-transport-CIoOBbO7.js.map +0 -1
- /package/dist/{cli/index.d.ts → index-BVVgDSdq.d.ts} +0 -0
package/dist/chat/index.js
CHANGED
|
@@ -1,67 +1,111 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isPlatformFailure, tryN } from "../retries.js";
|
|
2
2
|
import { i as normalizeToolInput, n as getPartialStreamText, r as isReplayChunk, t as applyChunkToParts } from "../message-builder-BymO4N_D.js";
|
|
3
3
|
import { i as interceptAgentToolBroadcast, n as applyAgentToolEvent, r as createAgentToolEventState, t as AgentToolProgressEmitter } from "../agent-tools-y7zLfw4Q.js";
|
|
4
|
-
import { t as
|
|
5
|
-
import { t as
|
|
6
|
-
import { i as StreamAccumulator, n as STREAM_RESUME_NONE_REASONS, r as transition, t as CHAT_MESSAGE_TYPES } from "../protocol-
|
|
4
|
+
import { t as TextSegmentJoiner } from "../text-segment-joiner-BtAFQSA_.js";
|
|
5
|
+
import { n as sanitizeMessage, t as byteLength } from "../sanitize-D9TujEK8.js";
|
|
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-DZKgAj9b.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/
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
43
|
-
|
|
23
|
+
output: truncateValue(output, maxChars, original.length, 0),
|
|
24
|
+
truncated: true,
|
|
25
|
+
originalLength: original.length
|
|
44
26
|
};
|
|
45
27
|
}
|
|
46
|
-
function
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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:
|
|
417
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
432
|
-
* The SQLite row limit is 2 MB; packing serializes bodies into a JSON
|
|
433
|
-
* which re-escapes their contents (quotes/backslashes), so we keep the
|
|
434
|
-
* total well under the limit to leave generous headroom for escaping
|
|
435
|
-
* A chunk larger than this is flushed as its own (unwrapped)
|
|
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
|
-
*
|
|
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
|
|
483
|
+
const REPLAY_PAGE_SEGMENTS = 10;
|
|
452
484
|
/**
|
|
453
485
|
* Retention for abandoned `streaming` rows, measured from LAST chunk activity.
|
|
454
486
|
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
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; anything a crash left behind
|
|
496
|
+
* is reclaimed by 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
|
-
*
|
|
466
|
-
*
|
|
467
|
-
*
|
|
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
|
|
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
|
-
*
|
|
478
|
-
*
|
|
479
|
-
*
|
|
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
|
|
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(
|
|
487
|
-
if (
|
|
523
|
+
const parsed = JSON.parse(row.metadata);
|
|
524
|
+
if (parsed && parsed.cfChat === 1) return parsed;
|
|
488
525
|
} catch {}
|
|
489
|
-
return
|
|
526
|
+
return null;
|
|
490
527
|
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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.
|
|
507
|
-
this.
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
|
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
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
-
*
|
|
778
|
+
* predates message-id tracking.
|
|
592
779
|
*/
|
|
593
780
|
getStreamMessageId(streamId) {
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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,61 @@ var ResumableStream = class ResumableStream {
|
|
|
610
788
|
*/
|
|
611
789
|
complete(streamId) {
|
|
612
790
|
this.flushBuffer();
|
|
613
|
-
this.
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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}.
|
|
818
|
+
*/
|
|
819
|
+
cutover(streamId, persist, options = {}) {
|
|
820
|
+
this.flushBuffer();
|
|
821
|
+
const discard = options.discard ?? true;
|
|
822
|
+
const settled = this.ops.settle(streamId, "completed", null, {
|
|
823
|
+
commit: persist,
|
|
824
|
+
discard
|
|
825
|
+
});
|
|
826
|
+
if (settled && discard) this._notifyProgress();
|
|
827
|
+
if (!settled) persist();
|
|
828
|
+
if (this._pendingCutover === streamId) this._pendingCutover = null;
|
|
829
|
+
this._clearActive();
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* Settle a {@link finish}ed stream that had nothing to persist (no parts,
|
|
833
|
+
* a persist that threw). Idempotent; a no-op when nothing is pending.
|
|
834
|
+
*/
|
|
835
|
+
finalizePending() {
|
|
836
|
+
const streamId = this._pendingCutover;
|
|
837
|
+
if (streamId === null) return;
|
|
838
|
+
this._pendingCutover = null;
|
|
839
|
+
this.ops.settle(streamId, "completed", null);
|
|
840
|
+
}
|
|
841
|
+
_clearActive() {
|
|
618
842
|
this._activeStreamId = null;
|
|
619
843
|
this._activeRequestId = null;
|
|
620
|
-
this._segmentIndex = 0;
|
|
621
844
|
this._isLive = false;
|
|
622
845
|
this._activeIsContinuation = false;
|
|
623
|
-
this._maybeCleanupOldStreams();
|
|
624
846
|
}
|
|
625
847
|
/**
|
|
626
848
|
* Mark a stream as errored and clean up state.
|
|
@@ -628,19 +850,12 @@ var ResumableStream = class ResumableStream {
|
|
|
628
850
|
*/
|
|
629
851
|
markError(streamId) {
|
|
630
852
|
this.flushBuffer();
|
|
631
|
-
this.
|
|
632
|
-
|
|
633
|
-
|
|
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;
|
|
853
|
+
this.ops.settle(streamId, "errored", null);
|
|
854
|
+
if (this._pendingCutover === streamId) this._pendingCutover = null;
|
|
855
|
+
this._clearActive();
|
|
641
856
|
}
|
|
642
857
|
/**
|
|
643
|
-
* Buffer a stream chunk for batch write to
|
|
858
|
+
* Buffer a stream chunk for batch write to storage.
|
|
644
859
|
* Chunks exceeding the row size limit are skipped to prevent crashes.
|
|
645
860
|
* The chunk is still broadcast to live clients (caller handles that),
|
|
646
861
|
* but will be missing from replay on reconnection.
|
|
@@ -648,8 +863,8 @@ var ResumableStream = class ResumableStream {
|
|
|
648
863
|
* @param body - The serialized chunk body
|
|
649
864
|
*/
|
|
650
865
|
storeChunk(streamId, body) {
|
|
651
|
-
const bodyBytes = textEncoder.encode(body).byteLength;
|
|
652
|
-
if (bodyBytes >
|
|
866
|
+
const bodyBytes = textEncoder.encode(JSON.stringify(body)).byteLength;
|
|
867
|
+
if (bodyBytes > CHUNK_MAX_BYTES) {
|
|
653
868
|
console.warn(`[ResumableStream] Skipping oversized chunk (${bodyBytes} bytes) to prevent SQLite row limit crash. Live clients still receive it.`);
|
|
654
869
|
return;
|
|
655
870
|
}
|
|
@@ -663,13 +878,14 @@ var ResumableStream = class ResumableStream {
|
|
|
663
878
|
if (this._chunkBuffer.length >= CHUNK_BUFFER_SIZE) this.flushBuffer();
|
|
664
879
|
}
|
|
665
880
|
/**
|
|
666
|
-
* Flush the buffered chunks to
|
|
881
|
+
* Flush the buffered chunks to storage as a single packed segment.
|
|
667
882
|
* Uses a lock to prevent concurrent flush operations.
|
|
668
883
|
*
|
|
669
|
-
* The whole buffer becomes one
|
|
670
|
-
*
|
|
671
|
-
* inflation, while a multi-chunk segment stores a JSON
|
|
672
|
-
* collapses N chunk
|
|
884
|
+
* The whole buffer becomes one stored chunk on the backing stream: a
|
|
885
|
+
* single-chunk segment is stored unwrapped so a large chunk avoids
|
|
886
|
+
* array-escaping inflation, while a multi-chunk segment stores a JSON
|
|
887
|
+
* array of bodies. This collapses N chunk writes into one fenced append,
|
|
888
|
+
* cutting rows written / stored / scanned.
|
|
673
889
|
*/
|
|
674
890
|
flushBuffer() {
|
|
675
891
|
if (this._isFlushingChunks || this._chunkBuffer.length === 0) return;
|
|
@@ -679,17 +895,33 @@ var ResumableStream = class ResumableStream {
|
|
|
679
895
|
this._chunkBuffer = [];
|
|
680
896
|
this._chunkBufferBytes = 0;
|
|
681
897
|
const streamId = chunks[0].streamId;
|
|
682
|
-
const
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
`;
|
|
687
|
-
this._segmentIndex++;
|
|
898
|
+
const segment = chunks.length === 1 ? chunks[0].body : chunks.map((chunk) => chunk.body);
|
|
899
|
+
try {
|
|
900
|
+
this.ops.append(streamId, segment);
|
|
901
|
+
} catch {}
|
|
688
902
|
} finally {
|
|
689
903
|
this._isFlushingChunks = false;
|
|
690
904
|
}
|
|
691
905
|
}
|
|
692
906
|
/**
|
|
907
|
+
* Stored chunk bodies for one stream, packed segments expanded, in order.
|
|
908
|
+
* A generator over paged reads, so replaying a large turn holds one page
|
|
909
|
+
* of segments in memory instead of the whole stored stream; iteration is
|
|
910
|
+
* synchronous end to end (WebSocket sends don't await), so the pages see
|
|
911
|
+
* a consistent log.
|
|
912
|
+
*/
|
|
913
|
+
*_storedBodies(streamId) {
|
|
914
|
+
let next = 0;
|
|
915
|
+
for (;;) {
|
|
916
|
+
const rows = this.ops.readChunks(streamId, next, REPLAY_PAGE_SEGMENTS);
|
|
917
|
+
for (const row of rows) {
|
|
918
|
+
next = row.seq + 1;
|
|
919
|
+
yield* unpackSegment(row.chunk);
|
|
920
|
+
}
|
|
921
|
+
if (rows.length < REPLAY_PAGE_SEGMENTS) return;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
693
925
|
* Send stored stream chunks to a connection for replay.
|
|
694
926
|
* Chunks are marked with replay: true so the client can batch-apply them.
|
|
695
927
|
*
|
|
@@ -699,11 +931,10 @@ var ResumableStream = class ResumableStream {
|
|
|
699
931
|
* - **Orphaned stream** (restored from SQLite after hibernation, no reader):
|
|
700
932
|
* sends chunks + `done` and completes the stream. The caller should
|
|
701
933
|
* reconstruct and persist the partial message from the stored chunks.
|
|
702
|
-
* - **Completed during replay** (defensive): sends chunks + `done`.
|
|
703
934
|
*
|
|
704
|
-
* All sends
|
|
705
|
-
*
|
|
706
|
-
*
|
|
935
|
+
* All sends tolerate a WebSocket closing mid-replay. If the connection
|
|
936
|
+
* drops while iterating chunks the stream is left active so the next
|
|
937
|
+
* reconnect can retry.
|
|
707
938
|
*
|
|
708
939
|
* @param connection - The WebSocket connection
|
|
709
940
|
* @param requestId - The original request ID
|
|
@@ -715,66 +946,41 @@ var ResumableStream = class ResumableStream {
|
|
|
715
946
|
if (!streamId) return null;
|
|
716
947
|
this.flushBuffer();
|
|
717
948
|
const continuation = this._activeIsContinuation;
|
|
718
|
-
|
|
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
|
-
}
|
|
949
|
+
if (!sendReplayBodies(connection, requestId, this._storedBodies(streamId), continuation)) return null;
|
|
742
950
|
if (!this._isLive) {
|
|
743
|
-
|
|
744
|
-
body: "",
|
|
951
|
+
sendReplayControl(connection, requestId, {
|
|
745
952
|
done: true,
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
replay: true,
|
|
749
|
-
...continuation && { continuation: true }
|
|
750
|
-
}));
|
|
953
|
+
continuation
|
|
954
|
+
});
|
|
751
955
|
this.complete(streamId);
|
|
752
956
|
return streamId;
|
|
753
957
|
}
|
|
754
|
-
|
|
755
|
-
body: "",
|
|
958
|
+
sendReplayControl(connection, requestId, {
|
|
756
959
|
done: false,
|
|
757
|
-
id: requestId,
|
|
758
|
-
type: CHAT_MESSAGE_TYPES.USE_CHAT_RESPONSE,
|
|
759
|
-
replay: true,
|
|
760
960
|
replayComplete: true,
|
|
761
|
-
|
|
762
|
-
})
|
|
961
|
+
continuation
|
|
962
|
+
});
|
|
763
963
|
return null;
|
|
764
964
|
}
|
|
965
|
+
/**
|
|
966
|
+
* Latest CHAT-owned row carrying a request tag. The stream table is
|
|
967
|
+
* shared with application producers and tags are non-unique, so the
|
|
968
|
+
* newest row by tag alone could be an unrelated stream masking chat's
|
|
969
|
+
* recovery evidence — ownership is the `cfChat` metadata marker.
|
|
970
|
+
*/
|
|
971
|
+
_latestChatRowByTag(requestId, state) {
|
|
972
|
+
return this.ops.rowsByTag(requestId, state).find((row) => parseChatMetadata(row) !== null);
|
|
973
|
+
}
|
|
765
974
|
replayCompletedChunksByRequestId(connection, requestId) {
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
975
|
+
this.flushBuffer();
|
|
976
|
+
const row = this._latestChatRowByTag(requestId, "completed");
|
|
977
|
+
if (!row) return false;
|
|
978
|
+
const continuation = parseChatMetadata(row)?.isContinuation === 1;
|
|
979
|
+
if (!sendReplayBodies(connection, requestId, this._storedBodies(row.stream_id), continuation)) return false;
|
|
980
|
+
return sendReplayControl(connection, requestId, {
|
|
772
981
|
done: true,
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
replay: true,
|
|
776
|
-
...continuation && { continuation: true }
|
|
777
|
-
}));
|
|
982
|
+
continuation
|
|
983
|
+
});
|
|
778
984
|
}
|
|
779
985
|
/**
|
|
780
986
|
* Replay the stored chunks of an errored stream for a request, WITHOUT a
|
|
@@ -782,9 +988,7 @@ var ResumableStream = class ResumableStream {
|
|
|
782
988
|
* frame carrying the durable terminal record's error text, mirroring what a
|
|
783
989
|
* live client observed (content chunks, then the error). Without this, a
|
|
784
990
|
* client that missed broadcast frames while disconnected has no other
|
|
785
|
-
* channel to the pre-error partial content
|
|
786
|
-
* messages on connect, and {@link replayCompletedChunksByRequestId} only
|
|
787
|
-
* serves `completed` streams (#1575).
|
|
991
|
+
* channel to the pre-error partial content (#1575).
|
|
788
992
|
*
|
|
789
993
|
* Returns true when the caller should proceed to send its terminal frame:
|
|
790
994
|
* either no errored stream existed (nothing to replay) or its chunks were
|
|
@@ -793,40 +997,46 @@ var ResumableStream = class ResumableStream {
|
|
|
793
997
|
* and the next reconnect retries the whole sequence.
|
|
794
998
|
*/
|
|
795
999
|
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
1000
|
this.flushBuffer();
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
and status = ${status}
|
|
807
|
-
order by created_at desc
|
|
808
|
-
limit 1
|
|
809
|
-
`[0];
|
|
1001
|
+
const row = this._latestChatRowByTag(requestId, "errored");
|
|
1002
|
+
if (!row) return true;
|
|
1003
|
+
return sendReplayBodies(connection, requestId, this._storedBodies(row.stream_id), parseChatMetadata(row)?.isContinuation === 1);
|
|
810
1004
|
}
|
|
811
1005
|
/**
|
|
812
|
-
*
|
|
813
|
-
*
|
|
1006
|
+
* Latest chat stream row for a request regardless of status — the recovery
|
|
1007
|
+
* engines' stream-evidence lookup.
|
|
814
1008
|
*/
|
|
815
|
-
|
|
816
|
-
const
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
return
|
|
1009
|
+
latestStreamInfoForRequest(requestId) {
|
|
1010
|
+
const row = this._latestChatRowByTag(requestId);
|
|
1011
|
+
if (!row) return null;
|
|
1012
|
+
return {
|
|
1013
|
+
id: row.stream_id,
|
|
1014
|
+
status: toPublicStatus(row.state),
|
|
1015
|
+
createdAt: row.created_at
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Latest in-flight chat stream for a request — recoverable-turn evidence.
|
|
1020
|
+
*/
|
|
1021
|
+
latestActiveStreamInfoForRequest(requestId) {
|
|
1022
|
+
const row = this._latestChatRowByTag(requestId, "streaming");
|
|
1023
|
+
if (!row) return null;
|
|
1024
|
+
return {
|
|
1025
|
+
id: row.stream_id,
|
|
1026
|
+
createdAt: row.created_at
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
/** Every chat-owned stream row, newest first. */
|
|
1030
|
+
_chatRows() {
|
|
1031
|
+
const rows = [];
|
|
1032
|
+
for (const row of this.ops.listRows()) {
|
|
1033
|
+
const chat = parseChatMetadata(row);
|
|
1034
|
+
if (chat) rows.push({
|
|
1035
|
+
...row,
|
|
1036
|
+
chat
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
return rows;
|
|
830
1040
|
}
|
|
831
1041
|
/**
|
|
832
1042
|
* Restore active stream state if the agent was restarted during streaming.
|
|
@@ -834,192 +1044,268 @@ var ResumableStream = class ResumableStream {
|
|
|
834
1044
|
* lazily in _maybeCleanupOldStreams after recovery has had its chance.
|
|
835
1045
|
*/
|
|
836
1046
|
restore() {
|
|
837
|
-
const
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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;
|
|
1047
|
+
const row = this._chatRows().find((r) => r.state === "streaming");
|
|
1048
|
+
if (row) {
|
|
1049
|
+
this._activeStreamId = row.stream_id;
|
|
1050
|
+
this._activeRequestId = row.tag;
|
|
1051
|
+
this._activeIsContinuation = row.chat.isContinuation === 1;
|
|
854
1052
|
}
|
|
855
1053
|
}
|
|
856
1054
|
/**
|
|
857
|
-
* Clear all stream data (called on chat history clear).
|
|
1055
|
+
* Clear all chat stream data (called on chat history clear). Streams other
|
|
1056
|
+
* producers opened on the same Durable Object are untouched.
|
|
858
1057
|
*/
|
|
859
1058
|
clearAll() {
|
|
860
1059
|
this._chunkBuffer = [];
|
|
861
1060
|
this._chunkBufferBytes = 0;
|
|
862
|
-
this.
|
|
863
|
-
this.sql`delete from cf_ai_chat_stream_metadata`;
|
|
1061
|
+
this._deleteRetiring(this._chatRows());
|
|
864
1062
|
this._activeStreamId = null;
|
|
865
1063
|
this._activeRequestId = null;
|
|
866
|
-
this._segmentIndex = 0;
|
|
867
1064
|
this._activeIsContinuation = false;
|
|
868
1065
|
}
|
|
869
1066
|
/**
|
|
870
|
-
*
|
|
1067
|
+
* Remove all chat stream data (called on destroy). The backing tables
|
|
1068
|
+
* belong to the Streams capability and are shared with other producers,
|
|
1069
|
+
* so this deletes chat's rows rather than dropping tables. Buffered
|
|
1070
|
+
* chunks are dropped (clearAll resets the buffer), not flushed: they
|
|
1071
|
+
* belong to a chat-owned stream this very call deletes, so writing them
|
|
1072
|
+
* first would only pay row writes for rows that die in the same
|
|
1073
|
+
* synchronous block.
|
|
871
1074
|
*/
|
|
872
1075
|
destroy() {
|
|
873
|
-
this.
|
|
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;
|
|
1076
|
+
this.clearAll();
|
|
879
1077
|
}
|
|
880
1078
|
/**
|
|
881
|
-
*
|
|
882
|
-
*
|
|
883
|
-
*
|
|
884
|
-
*
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
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
|
-
`;
|
|
1079
|
+
* Delete every chat stream row this Durable Object no longer needs:
|
|
1080
|
+
* finished streams (their messages are persisted; the cutover normally
|
|
1081
|
+
* deletes them in the same transaction, so these are crash leftovers) and
|
|
1082
|
+
* in-flight rows abandoned past {@link ABANDONED_STREAM_RETENTION_MS} by
|
|
1083
|
+
* last chunk activity. Runs on every {@link start}, so nothing needs an
|
|
1084
|
+
* alarm to be reclaimed; a Durable Object that never starts another turn
|
|
1085
|
+
* keeps at most one turn's rows. Streams other producers opened on the
|
|
1086
|
+
* same object are untouched.
|
|
1087
|
+
* @returns How many rows were reclaimed.
|
|
1088
|
+
*/
|
|
1089
|
+
reclaim(now = Date.now()) {
|
|
924
1090
|
const abandonedCutoff = now - ABANDONED_STREAM_RETENTION_MS;
|
|
925
|
-
this.
|
|
926
|
-
|
|
927
|
-
|
|
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
|
-
`;
|
|
1091
|
+
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);
|
|
1092
|
+
this._deleteRetiring(reclaimable);
|
|
1093
|
+
return reclaimable.length;
|
|
949
1094
|
}
|
|
950
1095
|
/**
|
|
951
1096
|
* Return the stored chunks for a stream as individual chunk bodies in order,
|
|
952
|
-
* unpacking packed
|
|
953
|
-
* per-chunk sequence (0, 1, 2, …) — stable across calls because
|
|
954
|
-
* append-only — so callers can use it as a monotonic chunk sequence.
|
|
1097
|
+
* unpacking packed segments. The returned `chunk_index` is a running
|
|
1098
|
+
* per-chunk sequence (0, 1, 2, …) — stable across calls because segments
|
|
1099
|
+
* are append-only — so callers can use it as a monotonic chunk sequence.
|
|
955
1100
|
*/
|
|
956
1101
|
getStreamChunks(streamId) {
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
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;
|
|
1102
|
+
return [...this._storedBodies(streamId)].map((body, chunk_index) => ({
|
|
1103
|
+
body,
|
|
1104
|
+
chunk_index
|
|
1105
|
+
}));
|
|
972
1106
|
}
|
|
973
1107
|
/** @internal For testing only */
|
|
974
1108
|
getStreamMetadata(streamId) {
|
|
975
|
-
const
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
1109
|
+
const row = this.ops.getStream(streamId);
|
|
1110
|
+
if (!row || !parseChatMetadata(row)) return null;
|
|
1111
|
+
return {
|
|
1112
|
+
status: toPublicStatus(row.state),
|
|
1113
|
+
request_id: row.tag ?? ""
|
|
1114
|
+
};
|
|
980
1115
|
}
|
|
981
1116
|
/** @internal For testing only */
|
|
982
1117
|
getAllStreamMetadata() {
|
|
983
|
-
return this.
|
|
1118
|
+
return this._chatRows().map((row) => ({
|
|
1119
|
+
id: row.stream_id,
|
|
1120
|
+
status: toPublicStatus(row.state),
|
|
1121
|
+
request_id: row.tag ?? "",
|
|
1122
|
+
created_at: row.created_at,
|
|
1123
|
+
message_id: row.chat.messageId ?? null
|
|
1124
|
+
}));
|
|
984
1125
|
}
|
|
985
1126
|
/** @internal For testing only */
|
|
986
1127
|
insertStaleStream(streamId, requestId, ageMs) {
|
|
987
1128
|
const createdAt = Date.now() - ageMs;
|
|
988
|
-
this.
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
1129
|
+
this.ops.importStream({
|
|
1130
|
+
streamId,
|
|
1131
|
+
state: "streaming",
|
|
1132
|
+
tag: requestId,
|
|
1133
|
+
metadata: { cfChat: 1 },
|
|
1134
|
+
chunkCount: 0,
|
|
1135
|
+
createdAt,
|
|
1136
|
+
updatedAt: createdAt,
|
|
1137
|
+
closedAt: null
|
|
1138
|
+
});
|
|
992
1139
|
}
|
|
993
1140
|
/**
|
|
994
|
-
* Append a chunk to a stream dated `ageMs` in the past. Used to exercise
|
|
995
|
-
*
|
|
996
|
-
* chunk must survive even when its
|
|
1141
|
+
* Append a chunk to a stream dated `ageMs` in the past. Used to exercise
|
|
1142
|
+
* reclaim's phase-2 verification: a long-running streaming row with a
|
|
1143
|
+
* *recent* chunk must survive even when its row `updated_at` (stamped at
|
|
1144
|
+
* open, not per append) is older than the coarse cutoff.
|
|
997
1145
|
* @internal For testing only
|
|
998
1146
|
*/
|
|
999
1147
|
insertChunkAt(streamId, body, ageMs) {
|
|
1000
|
-
|
|
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
|
-
`;
|
|
1148
|
+
this.ops.importChunk(streamId, body, Date.now() - ageMs);
|
|
1005
1149
|
}
|
|
1006
1150
|
};
|
|
1007
|
-
|
|
1151
|
+
//#endregion
|
|
1152
|
+
//#region src/chat/turn-task.ts
|
|
1153
|
+
/** Durable snapshot key for one chat turn's stash envelope. */
|
|
1154
|
+
function turnSnapshotKey(runId) {
|
|
1155
|
+
return `__cf_chat_turn_snapshot:${runId}`;
|
|
1156
|
+
}
|
|
1157
|
+
/**
|
|
1158
|
+
* Build the chat-turn Task handler for one host. Registered under the
|
|
1159
|
+
* host's `CHAT_FIBER_NAME` via `Tasks#register`.
|
|
1160
|
+
*/
|
|
1161
|
+
function createChatTurnTaskDefinition(hooks) {
|
|
1162
|
+
return async (input, step) => {
|
|
1163
|
+
const { requestId, nonce } = input;
|
|
1164
|
+
await step.do("model-turn", {
|
|
1165
|
+
retries: { limit: 1 },
|
|
1166
|
+
timeout: "1 day"
|
|
1167
|
+
}, async ({ signal }) => {
|
|
1168
|
+
const runId = `chat_${nonce}`;
|
|
1169
|
+
const snapshotKey = turnSnapshotKey(runId);
|
|
1170
|
+
const entry = hooks.getLiveClosure(nonce);
|
|
1171
|
+
if (!entry) {
|
|
1172
|
+
const persisted = await hooks.storage.get(snapshotKey);
|
|
1173
|
+
const createdAt = await hooks.getRunCreatedAt(runId) ?? Date.now();
|
|
1174
|
+
await hooks.handleRecovery({
|
|
1175
|
+
id: runId,
|
|
1176
|
+
name: `${hooks.definitionName}:${requestId}`,
|
|
1177
|
+
snapshot: persisted ?? null,
|
|
1178
|
+
createdAt,
|
|
1179
|
+
recoveryReason: "interrupted"
|
|
1180
|
+
});
|
|
1181
|
+
await hooks.storage.delete(snapshotKey);
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
await hooks.storage.put(snapshotKey, entry.initial);
|
|
1185
|
+
try {
|
|
1186
|
+
const value = await hooks.keepAliveWhile(() => hooks.withStash({
|
|
1187
|
+
id: nonce,
|
|
1188
|
+
signal,
|
|
1189
|
+
stash: (data) => void hooks.storage.put(snapshotKey, entry.wrap(data)).catch(() => {})
|
|
1190
|
+
}, () => entry.run()));
|
|
1191
|
+
entry.settle.resolve(value);
|
|
1192
|
+
} catch (error) {
|
|
1193
|
+
entry.settle.reject(error);
|
|
1194
|
+
throw error;
|
|
1195
|
+
} finally {
|
|
1196
|
+
hooks.storage.delete(snapshotKey).catch(() => {});
|
|
1197
|
+
}
|
|
1198
|
+
});
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
//#endregion
|
|
1202
|
+
//#region src/chat/recovery-task.ts
|
|
1008
1203
|
/**
|
|
1009
|
-
*
|
|
1010
|
-
*
|
|
1011
|
-
*
|
|
1012
|
-
*
|
|
1013
|
-
*
|
|
1014
|
-
*
|
|
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`.
|
|
1204
|
+
* Chat recovery transport on the Tasks capability.
|
|
1205
|
+
*
|
|
1206
|
+
* Each continuation attempt is one short Task run. The Task waits for any
|
|
1207
|
+
* requested backoff, dispatches the bounded host callback through model
|
|
1208
|
+
* handoff, then disappears after terminal settlement. The durable recovery
|
|
1209
|
+
* incident remains the source of truth for attempt and work budgets.
|
|
1017
1210
|
*
|
|
1018
|
-
*
|
|
1211
|
+
* @internal Sibling-package support for AI Chat and Think.
|
|
1019
1212
|
*/
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1213
|
+
/** Reserved Task definition shared by the chat hosts. */
|
|
1214
|
+
const CHAT_RECOVERY_TASK_NAME = "__cf_internal_chat_recovery";
|
|
1215
|
+
/**
|
|
1216
|
+
* Run a queue-driven recovery callback up to its model handoff, then return.
|
|
1217
|
+
*
|
|
1218
|
+
* The recovered turn can legitimately run for a long time, and awaiting it
|
|
1219
|
+
* would hold the Lifecycle job loop, starving every other job on the object.
|
|
1220
|
+
* A failure before the handoff rejects here, so the executing Task run (or
|
|
1221
|
+
* compatibility schedule row) keeps ownership and the driver's
|
|
1222
|
+
* platform-failure deferral applies (#1730). After the handoff the turn is
|
|
1223
|
+
* detached alarm work: a platform failure enqueues one replacement attempt,
|
|
1224
|
+
* retried a few times since the completed Task no longer owns this incident
|
|
1225
|
+
* and a failure here would otherwise abandon it silently, and any other
|
|
1226
|
+
* failure belongs to the turn's own incident bookkeeping.
|
|
1227
|
+
*/
|
|
1228
|
+
async function dispatchChatRecoveryToHandoff(handoff) {
|
|
1229
|
+
let handedOff = false;
|
|
1230
|
+
let signalHandoff = () => {};
|
|
1231
|
+
const reachedTurn = new Promise((resolve) => {
|
|
1232
|
+
signalHandoff = () => {
|
|
1233
|
+
handedOff = true;
|
|
1234
|
+
resolve();
|
|
1235
|
+
};
|
|
1236
|
+
});
|
|
1237
|
+
const turn = handoff.detached(signalHandoff);
|
|
1238
|
+
const tracked = reachedTurn.then(() => handoff.track(turn));
|
|
1239
|
+
turn.catch((error) => {
|
|
1240
|
+
if (!handedOff) return;
|
|
1241
|
+
if (isPlatformFailure(error)) {
|
|
1242
|
+
const dedupeKey = crypto.randomUUID();
|
|
1243
|
+
tryN(3, () => handoff.redefer(dedupeKey), {
|
|
1244
|
+
baseDelayMs: 50,
|
|
1245
|
+
maxDelayMs: 500
|
|
1246
|
+
}).catch((redeferError) => handoff.onDetachedError(redeferError));
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
handoff.onDetachedError(error);
|
|
1250
|
+
});
|
|
1251
|
+
await Promise.race([turn, tracked]);
|
|
1252
|
+
}
|
|
1253
|
+
function isRecord(value) {
|
|
1254
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1255
|
+
}
|
|
1256
|
+
function parseChatRecoveryTaskInput(input) {
|
|
1257
|
+
if (!isRecord(input)) throw new Error("Chat recovery Task input must be an object");
|
|
1258
|
+
const callback = input.callback;
|
|
1259
|
+
if (callback !== "_chatRecoveryContinue" && callback !== "_chatRecoveryRetry") throw new Error("Chat recovery Task input has an unknown callback");
|
|
1260
|
+
if (!isRecord(input.data)) throw new Error("Chat recovery Task input data must be an object");
|
|
1261
|
+
const delaySeconds = input.delaySeconds;
|
|
1262
|
+
if (typeof delaySeconds !== "number" || !Number.isFinite(delaySeconds) || delaySeconds < 0) throw new Error("Chat recovery Task delaySeconds must be a finite non-negative number");
|
|
1263
|
+
return {
|
|
1264
|
+
callback,
|
|
1265
|
+
data: input.data,
|
|
1266
|
+
delaySeconds
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* Build run options for one recovery attempt.
|
|
1271
|
+
*
|
|
1272
|
+
* Initial detection joins an existing in-flight attempt for the same incident
|
|
1273
|
+
* and callback. Chained retries are otherwise unkeyed because each one is
|
|
1274
|
+
* enqueued while the preceding run still exists — a genuinely new attempt,
|
|
1275
|
+
* not a retry of this same enqueue. `dedupeKey`, when supplied, keys this
|
|
1276
|
+
* specific enqueue call by `runId` instead: every retry of one failed
|
|
1277
|
+
* `redefer` (see {@link dispatchChatRecoveryToHandoff}) reuses the same key,
|
|
1278
|
+
* so a rejected-but-already-inserted attempt is joined rather than
|
|
1279
|
+
* duplicated. Non-retention releases the initial key when the run settles.
|
|
1280
|
+
*/
|
|
1281
|
+
function chatRecoveryTaskRunOptions(input, reason, dedupeKey) {
|
|
1282
|
+
const incidentId = typeof input.data.incidentId === "string" ? input.data.incidentId : void 0;
|
|
1283
|
+
const recoveredRequestId = typeof input.data.recoveredRequestId === "string" ? input.data.recoveredRequestId : void 0;
|
|
1284
|
+
return {
|
|
1285
|
+
retain: false,
|
|
1286
|
+
...dedupeKey !== void 0 ? { runId: dedupeKey } : {},
|
|
1287
|
+
...reason === "initial" && incidentId ? { idempotencyKey: `chat-recovery:${input.callback}:${incidentId}` } : {},
|
|
1288
|
+
metadata: {
|
|
1289
|
+
callback: input.callback,
|
|
1290
|
+
...incidentId ? { incidentId } : {},
|
|
1291
|
+
...recoveredRequestId ? { recoveredRequestId } : {}
|
|
1292
|
+
}
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
/** Build the shared recovery Task handler for one chat host. */
|
|
1296
|
+
function createChatRecoveryTaskDefinition(hooks) {
|
|
1297
|
+
return async (unknownInput, step) => {
|
|
1298
|
+
const input = parseChatRecoveryTaskInput(unknownInput);
|
|
1299
|
+
if (input.delaySeconds > 0) await step.sleep("backoff", input.delaySeconds * 1e3);
|
|
1300
|
+
await step.do("continuation", {
|
|
1301
|
+
retries: {
|
|
1302
|
+
limit: 3,
|
|
1303
|
+
delay: 100,
|
|
1304
|
+
backoff: "exponential"
|
|
1305
|
+
},
|
|
1306
|
+
timeout: "15 minutes"
|
|
1307
|
+
}, () => hooks[input.callback](input.data));
|
|
1308
|
+
};
|
|
1023
1309
|
}
|
|
1024
1310
|
//#endregion
|
|
1025
1311
|
//#region src/chat/sql-batch.ts
|
|
@@ -2293,11 +2579,12 @@ function partialHasSettledToolResults(parts) {
|
|
|
2293
2579
|
});
|
|
2294
2580
|
}
|
|
2295
2581
|
/**
|
|
2296
|
-
* The
|
|
2297
|
-
*
|
|
2298
|
-
*
|
|
2299
|
-
*
|
|
2300
|
-
*
|
|
2582
|
+
* The host-agnostic rule for crediting recovery forward progress from a stored
|
|
2583
|
+
* stream chunk, from when the marker was a counter bumped per chunk. Neither
|
|
2584
|
+
* host bumps a counter any more: the marker is derived from the stream log
|
|
2585
|
+
* (`ResumableStream.progressMarker`), so a chunk counts once its segment is
|
|
2586
|
+
* durably flushed. The rule stays exported for consumers that still keep a
|
|
2587
|
+
* counter of their own:
|
|
2301
2588
|
*
|
|
2302
2589
|
* - a **milestone** ({@link ChatRecoveryCodec.isProgressChunk}) always credits;
|
|
2303
2590
|
* - **streaming content** ({@link ChatRecoveryCodec.isStreamingContentChunk})
|
|
@@ -2464,10 +2751,14 @@ var ResumeHandshake = class {
|
|
|
2464
2751
|
//#region src/chat/recovery-incident.ts
|
|
2465
2752
|
const CHAT_RECOVERY_INCIDENT_KEY_PREFIX = "cf:chat-recovery:incident:";
|
|
2466
2753
|
/**
|
|
2467
|
-
*
|
|
2468
|
-
*
|
|
2469
|
-
*
|
|
2470
|
-
*
|
|
2754
|
+
* The pre-derivation forward-progress counter: a KV integer bumped per
|
|
2755
|
+
* credited chunk. The marker is now derived from the stream log
|
|
2756
|
+
* (`ResumableStream.progressMarker`), and this key is only read — once per
|
|
2757
|
+
* isolate, to seed the derived marker so it never reads lower than the
|
|
2758
|
+
* high-water mark an incident recorded before the upgrade. Current code
|
|
2759
|
+
* writes it only as a mirror of the derived marker's durable part — one put
|
|
2760
|
+
* per stream retired, none per chunk — so a build rolled back to the
|
|
2761
|
+
* counter never reads a marker lower than an incident recorded here.
|
|
2471
2762
|
*/
|
|
2472
2763
|
const CHAT_RECOVERY_PROGRESS_KEY = "cf:chat-recovery:progress";
|
|
2473
2764
|
/**
|
|
@@ -2511,8 +2802,17 @@ const DEFAULT_CHAT_RECOVERY_MAX_ATTEMPTS = 10;
|
|
|
2511
2802
|
* the incident is deleted). A very long agentic turn under heavy interruption
|
|
2512
2803
|
* that legitimately needs more should raise `maxRecoveryWork` (or set it to
|
|
2513
2804
|
* `Infinity` to restore the pre-#1825 unbounded behavior).
|
|
2805
|
+
*
|
|
2806
|
+
* The unit is one durable stream segment — about ten packed streaming chunks,
|
|
2807
|
+
* or one settled tool result, which is flushed on its own — plus one per
|
|
2808
|
+
* explicit credit for forwarded sub-agent output. The marker is derived from
|
|
2809
|
+
* the stream log (`ResumableStream.progressMarker`), so that is what it can
|
|
2810
|
+
* count. The earlier KV counter credited per milestone chunk and per five
|
|
2811
|
+
* seconds of deltas, a coarser measure of streamed text; 10 000 segments
|
|
2812
|
+
* (on the order of 100 000 chunks of re-run output) keeps the budget as
|
|
2813
|
+
* generous as 1 000 credits was for delta-heavy turns, and still finite.
|
|
2514
2814
|
*/
|
|
2515
|
-
const DEFAULT_CHAT_RECOVERY_MAX_WORK =
|
|
2815
|
+
const DEFAULT_CHAT_RECOVERY_MAX_WORK = 1e4;
|
|
2516
2816
|
/**
|
|
2517
2817
|
* Tight, OOM-specific retry budget (#1825). A Durable Object memory-limit reset
|
|
2518
2818
|
* (`isDurableObjectMemoryLimitReset`) is usually deterministic — the turn's
|
|
@@ -2561,12 +2861,12 @@ const CHAT_RECOVERY_ALARM_DEBOUNCE_MS = 30 * 1e3;
|
|
|
2561
2861
|
const CHAT_RECOVERING_FLAG_TTL_MS = 900 * 1e3;
|
|
2562
2862
|
/**
|
|
2563
2863
|
* Resolve a raw `chatRecovery` config field into the fully-defaulted form the
|
|
2564
|
-
* engine reasons about.
|
|
2864
|
+
* engine reasons about. Durable recovery is always enabled; a legacy runtime
|
|
2865
|
+
* `false` value from previously compiled JavaScript safely receives defaults.
|
|
2565
2866
|
*/
|
|
2566
2867
|
function resolveChatRecoveryConfig(raw) {
|
|
2567
2868
|
const custom = typeof raw === "object" && raw !== null ? raw : void 0;
|
|
2568
2869
|
return {
|
|
2569
|
-
enabled: raw !== false,
|
|
2570
2870
|
maxAttempts: Math.max(1, Math.floor(custom?.maxAttempts ?? 10)),
|
|
2571
2871
|
stableTimeoutMs: Math.max(0, Math.floor(custom?.stableTimeoutMs ?? 1e4)),
|
|
2572
2872
|
terminalMessage: custom?.terminalMessage ?? "The assistant was interrupted and could not recover. Please try again.",
|
|
@@ -2655,9 +2955,13 @@ async function readChatRecoveryProgress(storage) {
|
|
|
2655
2955
|
return await storage.get("cf:chat-recovery:progress") ?? 0;
|
|
2656
2956
|
}
|
|
2657
2957
|
/**
|
|
2658
|
-
* Advance the
|
|
2659
|
-
*
|
|
2660
|
-
*
|
|
2958
|
+
* Advance the KV progress counter by one.
|
|
2959
|
+
*
|
|
2960
|
+
* @deprecated Hosts no longer bump a counter per credited chunk: the marker
|
|
2961
|
+
* is derived from the stream log (`ResumableStream.progressMarker`) and
|
|
2962
|
+
* explicit credits go through `ResumableStream.creditProgress`. Kept for
|
|
2963
|
+
* code that still maintains the KV counter; a value written here is folded
|
|
2964
|
+
* into the derived marker on the next seed.
|
|
2661
2965
|
*/
|
|
2662
2966
|
async function bumpChatRecoveryProgress(storage) {
|
|
2663
2967
|
const current = await storage.get("cf:chat-recovery:progress") ?? 0;
|
|
@@ -2880,18 +3184,6 @@ async function evaluateChatRecoveryIncident(input) {
|
|
|
2880
3184
|
//#endregion
|
|
2881
3185
|
//#region src/chat/recovery-engine.ts
|
|
2882
3186
|
/**
|
|
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
3187
|
* Drives the shared recovery orchestration over a {@link ChatRecoveryAdapter}.
|
|
2896
3188
|
* The incident *budget math* lives in the pure `evaluateChatRecoveryIncident`;
|
|
2897
3189
|
* this class owns the surrounding sequence and its ordering invariants.
|
|
@@ -3069,7 +3361,8 @@ var ChatRecoveryEngine = class {
|
|
|
3069
3361
|
* 1. transition the incident to `scheduled` (persist + drive the #1620
|
|
3070
3362
|
* "recovering…" status) via {@link updateIncident};
|
|
3071
3363
|
* 2. emit `chat:recovery:scheduled`; and
|
|
3072
|
-
* 3. enqueue the callback through the adapter's
|
|
3364
|
+
* 3. enqueue the callback through the adapter's transport — a Task run on
|
|
3365
|
+
* a root agent, an idempotent schedule on the routed fallback.
|
|
3073
3366
|
*
|
|
3074
3367
|
* `recoveryKind` is passed explicitly (not read off the incident) because a
|
|
3075
3368
|
* caller can legitimately report a different kind than the incident was opened
|
|
@@ -3093,12 +3386,14 @@ var ChatRecoveryEngine = class {
|
|
|
3093
3386
|
}
|
|
3094
3387
|
/**
|
|
3095
3388
|
* Reschedule a recovery continuation/retry that timed out waiting for stable
|
|
3096
|
-
* state, INSIDE the currently-executing
|
|
3389
|
+
* state, from INSIDE the currently-executing recovery attempt (a
|
|
3390
|
+
* `__cf_internal_chat_recovery` Task run for a root agent, the routed
|
|
3391
|
+
* one-shot schedule row for a dynamic agent). Reads the
|
|
3097
3392
|
* incident; if it is still under the attempt cap, bumps `attempt`, marks it
|
|
3098
|
-
* `scheduled` with `reason:"stable_timeout_retry"`, and issues a
|
|
3099
|
-
*
|
|
3100
|
-
*
|
|
3101
|
-
*
|
|
3393
|
+
* `scheduled` with `reason:"stable_timeout_retry"`, and issues a separate
|
|
3394
|
+
* delayed attempt. It must not join the currently executing attempt: Tasks
|
|
3395
|
+
* enqueue an unkeyed chained run, while the routed fallback creates a
|
|
3396
|
+
* non-idempotent schedule.
|
|
3102
3397
|
*
|
|
3103
3398
|
* Returns `true` when a retry was scheduled, `false` when there is no incident
|
|
3104
3399
|
* (no id / record gone) or the attempt budget is already spent — in which case
|
|
@@ -3133,9 +3428,9 @@ var ChatRecoveryEngine = class {
|
|
|
3133
3428
|
* Bumps the incident's durable `oomAttempts` counter, then:
|
|
3134
3429
|
* - if it is still within `maxOomRetries`, issues a delayed, NON-idempotent
|
|
3135
3430
|
* reschedule of the SAME callback (same machinery as
|
|
3136
|
-
* {@link rescheduleAfterStableTimeout}: the executing
|
|
3137
|
-
*
|
|
3138
|
-
*
|
|
3431
|
+
* {@link rescheduleAfterStableTimeout}: the executing attempt settles
|
|
3432
|
+
* only after the callback returns, so an idempotent reschedule would
|
|
3433
|
+
* dedup onto that doomed attempt) and returns `"rescheduled"`. The small
|
|
3139
3434
|
* delay lets a transient memory spike clear before the re-run;
|
|
3140
3435
|
* - otherwise leaves the incremented count persisted (so a begin-path
|
|
3141
3436
|
* re-evaluation agrees) and returns `"exhausted"` — the caller then
|
|
@@ -3192,8 +3487,9 @@ var ChatRecoveryEngine = class {
|
|
|
3192
3487
|
* 6. terminalize via `exhaustChatRecovery` — BEFORE sealing. The terminal
|
|
3193
3488
|
* writes can reject with a platform transient in the deploy/storage window
|
|
3194
3489
|
* a give-up runs in (#1730); letting that throw propagate is deliberate, so
|
|
3195
|
-
*
|
|
3196
|
-
*
|
|
3490
|
+
* the current recovery attempt (a Task run on a root agent, a one-shot
|
|
3491
|
+
* schedule row on the routed fallback) defers and the WHOLE give-up
|
|
3492
|
+
* re-runs on a healthy isolate. Sealing first would arm the
|
|
3197
3493
|
* re-entry guard and turn that re-run into a no-op, dropping the durable
|
|
3198
3494
|
* terminal record. The re-run is idempotent (terminal writes overwrite the
|
|
3199
3495
|
* same key); a second banner is the documented at-least-once edge; and
|
|
@@ -3448,6 +3744,80 @@ async function* iterateWithStallWatchdog(source, timeoutMs, onStall) {
|
|
|
3448
3744
|
}
|
|
3449
3745
|
}
|
|
3450
3746
|
//#endregion
|
|
3451
|
-
|
|
3747
|
+
//#region src/chat/truncate-older-messages.ts
|
|
3748
|
+
/**
|
|
3749
|
+
* Read-time context truncation.
|
|
3750
|
+
*
|
|
3751
|
+
* Truncates older tool outputs and long text before sending to the LLM.
|
|
3752
|
+
* Structured tool outputs keep their container shape so tool-specific
|
|
3753
|
+
* `toModelOutput` handlers can safely replay older results.
|
|
3754
|
+
* Does NOT mutate stored messages — operates on a copy.
|
|
3755
|
+
*/
|
|
3756
|
+
/**
|
|
3757
|
+
* Truncate tool outputs and long text in older messages.
|
|
3758
|
+
* Returns a new array — input messages are not mutated.
|
|
3759
|
+
*
|
|
3760
|
+
* Recent messages (last `keepRecent`) are left intact.
|
|
3761
|
+
* Older messages get tool outputs and long text truncated. Structured tool
|
|
3762
|
+
* outputs are truncated in place instead of being replaced by raw strings.
|
|
3763
|
+
*
|
|
3764
|
+
* Use in assembleContext() before sending to the LLM:
|
|
3765
|
+
* ```typescript
|
|
3766
|
+
* async assembleContext() {
|
|
3767
|
+
* const history = this.sessions.getHistory(this._sessionId);
|
|
3768
|
+
* const truncated = truncateOlderMessages(history);
|
|
3769
|
+
* return convertToModelMessages(truncated);
|
|
3770
|
+
* }
|
|
3771
|
+
* ```
|
|
3772
|
+
*/
|
|
3773
|
+
function truncateOlderMessages(messages, options) {
|
|
3774
|
+
const keepRecent = options?.keepRecent ?? 4;
|
|
3775
|
+
const maxToolOutput = options?.maxToolOutputChars ?? 500;
|
|
3776
|
+
const maxText = options?.maxTextChars ?? 1e4;
|
|
3777
|
+
if (messages.length <= keepRecent) return messages;
|
|
3778
|
+
const cutoff = messages.length - keepRecent;
|
|
3779
|
+
const result = [];
|
|
3780
|
+
for (let i = 0; i < messages.length; i++) {
|
|
3781
|
+
if (i >= cutoff) {
|
|
3782
|
+
result.push(messages[i]);
|
|
3783
|
+
continue;
|
|
3784
|
+
}
|
|
3785
|
+
const msg = messages[i];
|
|
3786
|
+
let changed = false;
|
|
3787
|
+
const truncatedParts = msg.parts.map((part) => {
|
|
3788
|
+
if ((part.type.startsWith("tool-") || part.type === "dynamic-tool") && "output" in part) {
|
|
3789
|
+
const output = part.output;
|
|
3790
|
+
if (output !== void 0) {
|
|
3791
|
+
const truncated = truncateToolOutput(output, maxToolOutput);
|
|
3792
|
+
if (truncated.truncated) {
|
|
3793
|
+
changed = true;
|
|
3794
|
+
return {
|
|
3795
|
+
...part,
|
|
3796
|
+
output: truncated.output
|
|
3797
|
+
};
|
|
3798
|
+
}
|
|
3799
|
+
}
|
|
3800
|
+
}
|
|
3801
|
+
if (part.type === "text" && "text" in part) {
|
|
3802
|
+
const text = part.text;
|
|
3803
|
+
if (text.length > maxText) {
|
|
3804
|
+
changed = true;
|
|
3805
|
+
return {
|
|
3806
|
+
...part,
|
|
3807
|
+
text: `${text.slice(0, maxText)}... [truncated ${text.length} chars]`
|
|
3808
|
+
};
|
|
3809
|
+
}
|
|
3810
|
+
}
|
|
3811
|
+
return part;
|
|
3812
|
+
});
|
|
3813
|
+
result.push(changed ? {
|
|
3814
|
+
...msg,
|
|
3815
|
+
parts: truncatedParts
|
|
3816
|
+
} : msg);
|
|
3817
|
+
}
|
|
3818
|
+
return result;
|
|
3819
|
+
}
|
|
3820
|
+
//#endregion
|
|
3821
|
+
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
3822
|
|
|
3453
3823
|
//# sourceMappingURL=index.js.map
|