@tanstack/ai 0.42.0 → 0.43.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 +15 -1
- package/dist/esm/activities/chat/adapter.js +23 -16
- package/dist/esm/activities/chat/adapter.js.map +1 -1
- package/dist/esm/activities/chat/agent-loop-strategies.d.ts +5 -36
- package/dist/esm/activities/chat/agent-loop-strategies.js +75 -21
- package/dist/esm/activities/chat/agent-loop-strategies.js.map +1 -1
- package/dist/esm/activities/chat/cancel.d.ts +40 -0
- package/dist/esm/activities/chat/cancel.js +54 -0
- package/dist/esm/activities/chat/cancel.js.map +1 -0
- package/dist/esm/activities/chat/index.d.ts +28 -21
- package/dist/esm/activities/chat/index.js +2100 -1813
- package/dist/esm/activities/chat/index.js.map +1 -1
- package/dist/esm/activities/chat/mcp/manager.d.ts +2 -2
- package/dist/esm/activities/chat/mcp/manager.js +90 -77
- package/dist/esm/activities/chat/mcp/manager.js.map +1 -1
- package/dist/esm/activities/chat/mcp/types.d.ts +2 -2
- package/dist/esm/activities/chat/messages.js +397 -346
- package/dist/esm/activities/chat/messages.js.map +1 -1
- package/dist/esm/activities/chat/middleware/builder.js +17 -15
- package/dist/esm/activities/chat/middleware/builder.js.map +1 -1
- package/dist/esm/activities/chat/middleware/capabilities.js +78 -43
- package/dist/esm/activities/chat/middleware/capabilities.js.map +1 -1
- package/dist/esm/activities/chat/middleware/compose.d.ts +94 -1
- package/dist/esm/activities/chat/middleware/compose.js +623 -531
- package/dist/esm/activities/chat/middleware/compose.js.map +1 -1
- package/dist/esm/activities/chat/middleware/define.js +12 -5
- package/dist/esm/activities/chat/middleware/define.js.map +1 -1
- package/dist/esm/activities/chat/middleware/index.d.ts +5 -1
- package/dist/esm/activities/chat/middleware/locks.d.ts +50 -0
- package/dist/esm/activities/chat/middleware/locks.js +71 -0
- package/dist/esm/activities/chat/middleware/locks.js.map +1 -0
- package/dist/esm/activities/chat/middleware/pending-turn.d.ts +15 -0
- package/dist/esm/activities/chat/middleware/pending-turn.js +35 -0
- package/dist/esm/activities/chat/middleware/pending-turn.js.map +1 -0
- package/dist/esm/activities/chat/middleware/run-disconnect.d.ts +23 -0
- package/dist/esm/activities/chat/middleware/run-disconnect.js +42 -0
- package/dist/esm/activities/chat/middleware/run-disconnect.js.map +1 -0
- package/dist/esm/activities/chat/middleware/run-store.d.ts +283 -0
- package/dist/esm/activities/chat/middleware/run-store.js +176 -0
- package/dist/esm/activities/chat/middleware/run-store.js.map +1 -0
- package/dist/esm/activities/chat/middleware/sandbox-runtime.js +14 -8
- package/dist/esm/activities/chat/middleware/sandbox-runtime.js.map +1 -1
- package/dist/esm/activities/chat/middleware/tool-cache-middleware.js +79 -70
- package/dist/esm/activities/chat/middleware/tool-cache-middleware.js.map +1 -1
- package/dist/esm/activities/chat/middleware/types.d.ts +59 -2
- package/dist/esm/activities/chat/middleware/validate.js +23 -28
- package/dist/esm/activities/chat/middleware/validate.js.map +1 -1
- package/dist/esm/activities/chat/stream/json-parser.js +39 -25
- package/dist/esm/activities/chat/stream/json-parser.js.map +1 -1
- package/dist/esm/activities/chat/stream/message-updaters.js +275 -234
- package/dist/esm/activities/chat/stream/message-updaters.js.map +1 -1
- package/dist/esm/activities/chat/stream/processor.d.ts +24 -4
- package/dist/esm/activities/chat/stream/processor.js +1341 -1542
- package/dist/esm/activities/chat/stream/processor.js.map +1 -1
- package/dist/esm/activities/chat/stream/strategies.js +69 -53
- package/dist/esm/activities/chat/stream/strategies.js.map +1 -1
- package/dist/esm/activities/chat/tools/approval-schema.d.ts +19 -0
- package/dist/esm/activities/chat/tools/approval-schema.js +117 -0
- package/dist/esm/activities/chat/tools/approval-schema.js.map +1 -0
- package/dist/esm/activities/chat/tools/lazy-tool-manager.js +164 -191
- package/dist/esm/activities/chat/tools/lazy-tool-manager.js.map +1 -1
- package/dist/esm/activities/chat/tools/lazy-tools.js +24 -12
- package/dist/esm/activities/chat/tools/lazy-tools.js.map +1 -1
- package/dist/esm/activities/chat/tools/schema-converter.js +293 -146
- package/dist/esm/activities/chat/tools/schema-converter.js.map +1 -1
- package/dist/esm/activities/chat/tools/tool-calls.d.ts +18 -2
- package/dist/esm/activities/chat/tools/tool-calls.js +522 -531
- package/dist/esm/activities/chat/tools/tool-calls.js.map +1 -1
- package/dist/esm/activities/chat/tools/tool-definition.d.ts +75 -16
- package/dist/esm/activities/chat/tools/tool-definition.js +95 -23
- package/dist/esm/activities/chat/tools/tool-definition.js.map +1 -1
- package/dist/esm/activities/error-payload.js +85 -47
- package/dist/esm/activities/error-payload.js.map +1 -1
- package/dist/esm/activities/generateAudio/adapter.js +22 -15
- package/dist/esm/activities/generateAudio/adapter.js.map +1 -1
- package/dist/esm/activities/generateAudio/index.d.ts +4 -0
- package/dist/esm/activities/generateAudio/index.js +141 -105
- package/dist/esm/activities/generateAudio/index.js.map +1 -1
- package/dist/esm/activities/generateImage/adapter.js +22 -15
- package/dist/esm/activities/generateImage/adapter.js.map +1 -1
- package/dist/esm/activities/generateImage/index.d.ts +4 -0
- package/dist/esm/activities/generateImage/index.js +155 -111
- package/dist/esm/activities/generateImage/index.js.map +1 -1
- package/dist/esm/activities/generateSpeech/adapter.js +22 -15
- package/dist/esm/activities/generateSpeech/adapter.js.map +1 -1
- package/dist/esm/activities/generateSpeech/index.d.ts +4 -0
- package/dist/esm/activities/generateSpeech/index.js +159 -110
- package/dist/esm/activities/generateSpeech/index.js.map +1 -1
- package/dist/esm/activities/generateTranscription/adapter.js +22 -15
- package/dist/esm/activities/generateTranscription/adapter.js.map +1 -1
- package/dist/esm/activities/generateTranscription/index.d.ts +4 -0
- package/dist/esm/activities/generateTranscription/index.js +159 -100
- package/dist/esm/activities/generateTranscription/index.js.map +1 -1
- package/dist/esm/activities/generateVideo/adapter.js +36 -29
- package/dist/esm/activities/generateVideo/adapter.js.map +1 -1
- package/dist/esm/activities/generateVideo/index.d.ts +143 -19
- package/dist/esm/activities/generateVideo/index.js +456 -279
- package/dist/esm/activities/generateVideo/index.js.map +1 -1
- package/dist/esm/activities/generateVideo/snap.js +60 -48
- package/dist/esm/activities/generateVideo/snap.js.map +1 -1
- package/dist/esm/activities/index.js +8 -34
- package/dist/esm/activities/middleware/index.d.ts +1 -1
- package/dist/esm/activities/middleware/run.d.ts +10 -0
- package/dist/esm/activities/middleware/run.js +53 -29
- package/dist/esm/activities/middleware/run.js.map +1 -1
- package/dist/esm/activities/middleware/types.d.ts +44 -6
- package/dist/esm/activities/stream-generation-result.d.ts +4 -1
- package/dist/esm/activities/stream-generation-result.js +79 -44
- package/dist/esm/activities/stream-generation-result.js.map +1 -1
- package/dist/esm/activities/summarize/adapter.js +22 -15
- package/dist/esm/activities/summarize/adapter.js.map +1 -1
- package/dist/esm/activities/summarize/chat-stream-summarize.js +252 -202
- package/dist/esm/activities/summarize/chat-stream-summarize.js.map +1 -1
- package/dist/esm/activities/summarize/index.d.ts +27 -0
- package/dist/esm/activities/summarize/index.js +268 -102
- package/dist/esm/activities/summarize/index.js.map +1 -1
- package/dist/esm/adapter-internals.d.ts +2 -1
- package/dist/esm/adapter-internals.js +4 -11
- package/dist/esm/client.d.ts +25 -3
- package/dist/esm/client.js +131 -64
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/custom-events.d.ts +76 -0
- package/dist/esm/custom-events.js +37 -0
- package/dist/esm/custom-events.js.map +1 -0
- package/dist/esm/delivery-detach.d.ts +50 -0
- package/dist/esm/delivery-detach.js +71 -0
- package/dist/esm/delivery-detach.js.map +1 -0
- package/dist/esm/delivery-disconnect.d.ts +62 -0
- package/dist/esm/delivery-disconnect.js +81 -0
- package/dist/esm/delivery-disconnect.js.map +1 -0
- package/dist/esm/extend-adapter.js +19 -17
- package/dist/esm/extend-adapter.js.map +1 -1
- package/dist/esm/index.d.ts +24 -6
- package/dist/esm/index.js +30 -98
- package/dist/esm/interrupt-resume.d.ts +71 -0
- package/dist/esm/interrupt-resume.js +438 -0
- package/dist/esm/interrupt-resume.js.map +1 -0
- package/dist/esm/interrupt-serialization.d.ts +12 -0
- package/dist/esm/interrupt-serialization.js +178 -0
- package/dist/esm/interrupt-serialization.js.map +1 -0
- package/dist/esm/interrupts.d.ts +84 -0
- package/dist/esm/interrupts.js +31 -0
- package/dist/esm/interrupts.js.map +1 -0
- package/dist/esm/locks.d.ts +10 -0
- package/dist/esm/locks.js +2 -0
- package/dist/esm/logger/console-logger.js +101 -78
- package/dist/esm/logger/console-logger.js.map +1 -1
- package/dist/esm/logger/internal-logger.js +104 -89
- package/dist/esm/logger/internal-logger.js.map +1 -1
- package/dist/esm/logger/resolve.js +54 -49
- package/dist/esm/logger/resolve.js.map +1 -1
- package/dist/esm/logger/types.d.ts +1 -1
- package/dist/esm/middlewares/content-guard.js +142 -148
- package/dist/esm/middlewares/content-guard.js.map +1 -1
- package/dist/esm/middlewares/index.js +2 -6
- package/dist/esm/middlewares/otel.js +598 -732
- package/dist/esm/middlewares/otel.js.map +1 -1
- package/dist/esm/middlewares/usage-attributes.js +47 -40
- package/dist/esm/middlewares/usage-attributes.js.map +1 -1
- package/dist/esm/realtime/event-emitter.js +24 -25
- package/dist/esm/realtime/event-emitter.js.map +1 -1
- package/dist/esm/realtime/index.d.ts +5 -9
- package/dist/esm/realtime/index.js +29 -6
- package/dist/esm/realtime/index.js.map +1 -1
- package/dist/esm/scope.d.ts +47 -0
- package/dist/esm/stream-durability.d.ts +171 -0
- package/dist/esm/stream-durability.js +295 -0
- package/dist/esm/stream-durability.js.map +1 -0
- package/dist/esm/stream-to-response.d.ts +178 -13
- package/dist/esm/stream-to-response.js +663 -115
- package/dist/esm/stream-to-response.js.map +1 -1
- package/dist/esm/strip-to-spec-middleware.js +30 -16
- package/dist/esm/strip-to-spec-middleware.js.map +1 -1
- package/dist/esm/system-prompts.js +27 -21
- package/dist/esm/system-prompts.js.map +1 -1
- package/dist/esm/tool-registry.js +72 -45
- package/dist/esm/tool-registry.js.map +1 -1
- package/dist/esm/tools/provider-tool.js +14 -5
- package/dist/esm/tools/provider-tool.js.map +1 -1
- package/dist/esm/types.d.ts +321 -42
- package/dist/esm/types.js +2 -0
- package/dist/esm/utilities/ag-ui-wire.js +79 -93
- package/dist/esm/utilities/ag-ui-wire.js.map +1 -1
- package/dist/esm/utilities/chat-params.d.ts +26 -4
- package/dist/esm/utilities/chat-params.js +218 -92
- package/dist/esm/utilities/chat-params.js.map +1 -1
- package/dist/esm/utilities/errors.js +28 -18
- package/dist/esm/utilities/errors.js.map +1 -1
- package/dist/esm/utilities/media-prompt.js +46 -41
- package/dist/esm/utilities/media-prompt.js.map +1 -1
- package/dist/esm/utilities/numbers.js +13 -10
- package/dist/esm/utilities/numbers.js.map +1 -1
- package/dist/esm/utilities/provider-executed.js +20 -11
- package/dist/esm/utilities/provider-executed.js.map +1 -1
- package/dist/esm/utilities/sampling-keys.js +31 -19
- package/dist/esm/utilities/sampling-keys.js.map +1 -1
- package/dist/esm/utilities/tool-result.js +42 -30
- package/dist/esm/utilities/tool-result.js.map +1 -1
- package/dist/esm/utilities/usage.js +27 -9
- package/dist/esm/utilities/usage.js.map +1 -1
- package/dist/esm/utils.js +26 -18
- package/dist/esm/utils.js.map +1 -1
- package/package.json +10 -6
- package/skills/ai-core/SKILL.md +69 -18
- package/skills/ai-core/adapter-configuration/SKILL.md +44 -21
- package/skills/ai-core/adapter-configuration/references/anthropic-adapter.md +1 -3
- package/skills/ai-core/adapter-configuration/references/byteplus-adapter.md +148 -0
- package/skills/ai-core/adapter-configuration/references/gemini-adapter.md +2 -6
- package/skills/ai-core/adapter-configuration/references/groq-adapter.md +2 -6
- package/skills/ai-core/adapter-configuration/references/openai-adapter.md +1 -3
- package/skills/ai-core/ag-ui-protocol/SKILL.md +1 -1
- package/skills/ai-core/chat-experience/SKILL.md +98 -11
- package/skills/ai-core/client-persistence/SKILL.md +277 -0
- package/skills/ai-core/custom-backend-integration/SKILL.md +1 -1
- package/skills/ai-core/debug-logging/SKILL.md +1 -1
- package/skills/ai-core/locks/SKILL.md +143 -0
- package/skills/ai-core/media-generation/SKILL.md +144 -12
- package/skills/ai-core/middleware/SKILL.md +258 -33
- package/skills/ai-core/structured-outputs/SKILL.md +1 -1
- package/skills/ai-core/tool-calling/SKILL.md +54 -61
- package/src/activities/chat/agent-loop-strategies.ts +5 -39
- package/src/activities/chat/cancel.ts +81 -0
- package/src/activities/chat/index.ts +1091 -200
- package/src/activities/chat/mcp/manager.ts +4 -4
- package/src/activities/chat/mcp/types.ts +2 -2
- package/src/activities/chat/messages.ts +5 -3
- package/src/activities/chat/middleware/builder.ts +1 -1
- package/src/activities/chat/middleware/compose.ts +186 -9
- package/src/activities/chat/middleware/index.ts +26 -0
- package/src/activities/chat/middleware/locks.ts +102 -0
- package/src/activities/chat/middleware/pending-turn.ts +47 -0
- package/src/activities/chat/middleware/run-disconnect.ts +62 -0
- package/src/activities/chat/middleware/run-store.ts +412 -0
- package/src/activities/chat/middleware/types.ts +62 -1
- package/src/activities/chat/stream/processor.ts +189 -5
- package/src/activities/chat/tools/approval-schema.ts +205 -0
- package/src/activities/chat/tools/tool-calls.ts +106 -13
- package/src/activities/chat/tools/tool-definition.ts +210 -39
- package/src/activities/generateAudio/index.ts +20 -3
- package/src/activities/generateImage/index.ts +20 -3
- package/src/activities/generateSpeech/index.ts +25 -3
- package/src/activities/generateTranscription/index.ts +26 -3
- package/src/activities/generateVideo/index.ts +345 -82
- package/src/activities/middleware/index.ts +2 -0
- package/src/activities/middleware/run.ts +31 -0
- package/src/activities/middleware/types.ts +49 -5
- package/src/activities/stream-generation-result.ts +30 -2
- package/src/activities/summarize/chat-stream-summarize.ts +5 -0
- package/src/activities/summarize/index.ts +200 -10
- package/src/adapter-internals.ts +10 -1
- package/src/client.ts +244 -0
- package/src/custom-events.ts +107 -0
- package/src/delivery-detach.ts +72 -0
- package/src/delivery-disconnect.ts +84 -0
- package/src/index.ts +138 -1
- package/src/interrupt-resume.ts +824 -0
- package/src/interrupt-serialization.ts +183 -0
- package/src/interrupts.ts +146 -0
- package/src/locks.ts +17 -0
- package/src/logger/types.ts +1 -1
- package/src/middlewares/otel.ts +1 -0
- package/src/realtime/index.ts +5 -9
- package/src/scope.ts +47 -0
- package/src/stream-durability.ts +598 -0
- package/src/stream-to-response.ts +1051 -95
- package/src/strip-to-spec-middleware.ts +3 -3
- package/src/types.ts +405 -45
- package/src/utilities/chat-params.ts +245 -55
- package/dist/esm/activities/index.js.map +0 -1
- package/dist/esm/adapter-internals.js.map +0 -1
- package/dist/esm/index.js.map +0 -1
- package/dist/esm/middlewares/index.js.map +0 -1
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
import { toRunErrorPayload } from './activities/error-payload'
|
|
2
|
+
import { isCancelRequestedReason } from './activities/chat/cancel'
|
|
3
|
+
import {
|
|
4
|
+
isRunStatus,
|
|
5
|
+
isTerminalRunStatus,
|
|
6
|
+
} from './activities/chat/middleware/run-store'
|
|
7
|
+
import { wasRunDetached } from './delivery-detach'
|
|
8
|
+
import { notifyRunDisconnected } from './delivery-disconnect'
|
|
9
|
+
import { resolveResumeRunId } from './stream-durability'
|
|
10
|
+
import { EventType } from './types'
|
|
11
|
+
import { resolveDebugOption } from './logger/resolve'
|
|
12
|
+
import type { LockStore } from './activities/chat/middleware/locks'
|
|
13
|
+
import type {
|
|
14
|
+
RunRecord,
|
|
15
|
+
RunStore,
|
|
16
|
+
} from './activities/chat/middleware/run-store'
|
|
17
|
+
import type { InternalLogger } from './logger/internal-logger'
|
|
18
|
+
import type { DebugOption } from './logger/types'
|
|
19
|
+
import type { StreamDurability } from './stream-durability'
|
|
2
20
|
import type { StreamChunk } from './types'
|
|
3
21
|
|
|
22
|
+
export { resolveResumeRunId } from './stream-durability'
|
|
23
|
+
|
|
4
24
|
/**
|
|
5
25
|
* Collect all text content from a StreamChunk async iterable and return as a string.
|
|
6
26
|
*
|
|
@@ -13,8 +33,7 @@ import type { StreamChunk } from './types'
|
|
|
13
33
|
* @example
|
|
14
34
|
* ```typescript
|
|
15
35
|
* const stream = chat({
|
|
16
|
-
* adapter: openaiText(),
|
|
17
|
-
* model: 'gpt-4o',
|
|
36
|
+
* adapter: openaiText('gpt-5.5'),
|
|
18
37
|
* messages: [{ role: 'user', content: 'Hello!' }]
|
|
19
38
|
* });
|
|
20
39
|
* const text = await streamToText(stream);
|
|
@@ -35,6 +54,196 @@ export async function streamToText(
|
|
|
35
54
|
return accumulatedContent
|
|
36
55
|
}
|
|
37
56
|
|
|
57
|
+
interface RecordedFailure {
|
|
58
|
+
error: unknown
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function errorMessage(error: unknown): string {
|
|
62
|
+
return toRunErrorPayload(error).message
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function combineFailures(
|
|
66
|
+
primary: unknown,
|
|
67
|
+
secondary: unknown,
|
|
68
|
+
phase: string,
|
|
69
|
+
): unknown {
|
|
70
|
+
if (primary === secondary) return primary
|
|
71
|
+
const errors =
|
|
72
|
+
primary instanceof AggregateError
|
|
73
|
+
? [...primary.errors, secondary]
|
|
74
|
+
: [primary, secondary]
|
|
75
|
+
return new AggregateError(
|
|
76
|
+
errors,
|
|
77
|
+
`${errorMessage(primary)}; ${phase}: ${errorMessage(secondary)}`,
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function runErrorChunk(
|
|
82
|
+
error: unknown,
|
|
83
|
+
): Extract<StreamChunk, { type: 'RUN_ERROR' }> {
|
|
84
|
+
const payload = toRunErrorPayload(error)
|
|
85
|
+
return {
|
|
86
|
+
type: EventType.RUN_ERROR,
|
|
87
|
+
timestamp: Date.now(),
|
|
88
|
+
message: payload.message,
|
|
89
|
+
...(payload.code === undefined ? {} : { code: payload.code }),
|
|
90
|
+
error: payload,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isAborted(signal: AbortSignal): boolean {
|
|
95
|
+
return signal.aborted
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Whether this abort is an EXPLICIT in-process cancel — the caller aborted with
|
|
100
|
+
* {@link RUN_CANCEL_REASON} rather than the socket going away.
|
|
101
|
+
*
|
|
102
|
+
* Core's own guard, independent of any middleware verdict: a user pressing Stop
|
|
103
|
+
* must always get a closed, terminal log, so the sink refuses to treat that abort
|
|
104
|
+
* as a detach even if the run's middleware published one. A reason-less abort
|
|
105
|
+
* carries a `DOMException`, never a string, so a non-string reason is "no
|
|
106
|
+
* explicit intent" — exactly how `resolveAbortReason` reads it in `chat()`.
|
|
107
|
+
*/
|
|
108
|
+
function isExplicitCancel(signal: AbortSignal): boolean {
|
|
109
|
+
const reason: unknown = signal.reason
|
|
110
|
+
return typeof reason === 'string' && isCancelRequestedReason(reason)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function needsTerminalPersistence(
|
|
114
|
+
terminalPersisted: boolean,
|
|
115
|
+
cancelled: boolean,
|
|
116
|
+
failed: boolean,
|
|
117
|
+
): boolean {
|
|
118
|
+
return !terminalPersisted && (cancelled || failed)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function toEncodedStream(
|
|
122
|
+
stream: AsyncIterable<StreamChunk>,
|
|
123
|
+
abortController: AbortController | undefined,
|
|
124
|
+
encodeChunk: (chunk: StreamChunk, index: number) => Uint8Array,
|
|
125
|
+
encodeError: (error: unknown) => Uint8Array,
|
|
126
|
+
detachOnCancel = false,
|
|
127
|
+
/**
|
|
128
|
+
* Called once when the response body is cancelled on the detach path, BEFORE
|
|
129
|
+
* returning. The durability branch uses it to tell the run its viewer is gone
|
|
130
|
+
* (see `./delivery-disconnect`) without aborting it.
|
|
131
|
+
*/
|
|
132
|
+
onDetachedCancel?: () => void,
|
|
133
|
+
): ReadableStream<Uint8Array> {
|
|
134
|
+
const cancellation = abortController ?? new AbortController()
|
|
135
|
+
let iterator: AsyncIterator<StreamChunk> | undefined
|
|
136
|
+
let iteratorCleanup: Promise<void> | undefined
|
|
137
|
+
let pumpPromise: Promise<void> = Promise.resolve()
|
|
138
|
+
let pumpFailure: RecordedFailure | undefined
|
|
139
|
+
let cancelled = false
|
|
140
|
+
|
|
141
|
+
const recordPumpFailure = (error: unknown, phase: string): void => {
|
|
142
|
+
pumpFailure = {
|
|
143
|
+
error:
|
|
144
|
+
pumpFailure === undefined
|
|
145
|
+
? error
|
|
146
|
+
: combineFailures(pumpFailure.error, error, phase),
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const closeIterator = (): Promise<void> => {
|
|
151
|
+
iteratorCleanup ??= (async () => {
|
|
152
|
+
if (iterator?.return) await iterator.return()
|
|
153
|
+
})()
|
|
154
|
+
return iteratorCleanup
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return new ReadableStream({
|
|
158
|
+
start(controller) {
|
|
159
|
+
iterator = stream[Symbol.asyncIterator]()
|
|
160
|
+
pumpPromise = (async () => {
|
|
161
|
+
let index = 0
|
|
162
|
+
let iteratorDone = false
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
while (!isAborted(cancellation.signal)) {
|
|
166
|
+
const result = await iterator.next()
|
|
167
|
+
if (result.done) {
|
|
168
|
+
iteratorDone = true
|
|
169
|
+
break
|
|
170
|
+
}
|
|
171
|
+
if (isAborted(cancellation.signal)) break
|
|
172
|
+
// After a detached cancel the reader is gone but we keep pulling to
|
|
173
|
+
// drain the producer into the durable log; skip enqueuing to the
|
|
174
|
+
// closed controller.
|
|
175
|
+
if (!cancelled) controller.enqueue(encodeChunk(result.value, index))
|
|
176
|
+
index += 1
|
|
177
|
+
}
|
|
178
|
+
} catch (error) {
|
|
179
|
+
recordPumpFailure(error, 'stream iteration failed')
|
|
180
|
+
} finally {
|
|
181
|
+
if (!iteratorDone) {
|
|
182
|
+
try {
|
|
183
|
+
await closeIterator()
|
|
184
|
+
} catch (error) {
|
|
185
|
+
recordPumpFailure(error, 'iterator cleanup failed')
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (
|
|
190
|
+
!cancelled &&
|
|
191
|
+
!isAborted(cancellation.signal) &&
|
|
192
|
+
pumpFailure !== undefined
|
|
193
|
+
) {
|
|
194
|
+
controller.enqueue(encodeError(pumpFailure.error))
|
|
195
|
+
}
|
|
196
|
+
if (!cancelled) controller.close()
|
|
197
|
+
}
|
|
198
|
+
})().catch((error: unknown) => {
|
|
199
|
+
recordPumpFailure(error, 'stream pump failed')
|
|
200
|
+
})
|
|
201
|
+
},
|
|
202
|
+
async cancel(reason) {
|
|
203
|
+
cancelled = true
|
|
204
|
+
// Detached durable delivery: the client is gone (e.g. a page reload), but
|
|
205
|
+
// the run must finish into the durable log so a rejoining client can tail
|
|
206
|
+
// it to the real terminal. Do NOT abort the producer (that would kill the
|
|
207
|
+
// run and seal the log with RUN_ERROR) and do NOT await the pump — it
|
|
208
|
+
// keeps draining `stream` → the log in the background and terminates
|
|
209
|
+
// normally on its own. A genuine caller-driven stop aborts the producer's
|
|
210
|
+
// own AbortController instead, which this path never touches.
|
|
211
|
+
//
|
|
212
|
+
// Notify the run FIRST, and synchronously. This is the only moment the
|
|
213
|
+
// socket-closed fact exists anywhere, and the run cannot observe it on its
|
|
214
|
+
// own: it holds no handle on this response. That notification is what lets a
|
|
215
|
+
// durable run record itself as detached while it KEEPS RUNNING — the
|
|
216
|
+
// alternative applications were driven to (mirroring `request.signal` into
|
|
217
|
+
// `chat()`'s abortController) reaches the middleware only by killing the run,
|
|
218
|
+
// which for a sandboxed run means the agent is never even launched.
|
|
219
|
+
if (detachOnCancel) {
|
|
220
|
+
onDetachedCancel?.()
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!isAborted(cancellation.signal)) cancellation.abort(reason)
|
|
225
|
+
|
|
226
|
+
let cancellationFailure: RecordedFailure | undefined
|
|
227
|
+
try {
|
|
228
|
+
await closeIterator()
|
|
229
|
+
} catch (error) {
|
|
230
|
+
cancellationFailure = { error }
|
|
231
|
+
}
|
|
232
|
+
await pumpPromise
|
|
233
|
+
|
|
234
|
+
if (pumpFailure !== undefined && cancellationFailure !== undefined) {
|
|
235
|
+
throw combineFailures(
|
|
236
|
+
pumpFailure.error,
|
|
237
|
+
cancellationFailure.error,
|
|
238
|
+
'iterator cancellation failed',
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
if (pumpFailure !== undefined) throw pumpFailure.error
|
|
242
|
+
if (cancellationFailure !== undefined) throw cancellationFailure.error
|
|
243
|
+
},
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
|
|
38
247
|
/**
|
|
39
248
|
* Convert a StreamChunk async iterable to a ReadableStream in Server-Sent Events format
|
|
40
249
|
*
|
|
@@ -45,58 +254,414 @@ export async function streamToText(
|
|
|
45
254
|
*
|
|
46
255
|
* @param stream - AsyncIterable of StreamChunks from chat()
|
|
47
256
|
* @param abortController - Optional AbortController to abort when stream is cancelled
|
|
257
|
+
* @param getId - Optional per-chunk durability offset; when present, each event gets an `id:` line
|
|
48
258
|
* @returns ReadableStream in Server-Sent Events format
|
|
49
259
|
*/
|
|
50
260
|
export function toServerSentEventsStream(
|
|
51
261
|
stream: AsyncIterable<StreamChunk>,
|
|
52
262
|
abortController?: AbortController,
|
|
263
|
+
getId?: (chunk: StreamChunk, index: number) => string | undefined,
|
|
53
264
|
): ReadableStream<Uint8Array> {
|
|
265
|
+
const { encodeChunk, encodeError } = sseEncoders(getId)
|
|
266
|
+
return toEncodedStream(stream, abortController, encodeChunk, encodeError)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* SSE chunk/error encoders. Shared by the public {@link toServerSentEventsStream}
|
|
271
|
+
* and the internal durability branch (which additionally needs `toEncodedStream`'s
|
|
272
|
+
* private `detachOnCancel`), so the wire format stays identical for both.
|
|
273
|
+
*/
|
|
274
|
+
function sseEncoders(
|
|
275
|
+
getId?: (chunk: StreamChunk, index: number) => string | undefined,
|
|
276
|
+
): {
|
|
277
|
+
encodeChunk: (chunk: StreamChunk, index: number) => Uint8Array
|
|
278
|
+
encodeError: (error: unknown) => Uint8Array
|
|
279
|
+
} {
|
|
54
280
|
const encoder = new TextEncoder()
|
|
281
|
+
return {
|
|
282
|
+
encodeChunk: (chunk, index) => {
|
|
283
|
+
const id = getId?.(chunk, index)
|
|
284
|
+
const idLine = id === undefined ? '' : `id: ${id}\n`
|
|
285
|
+
return encoder.encode(`${idLine}data: ${JSON.stringify(chunk)}\n\n`)
|
|
286
|
+
},
|
|
287
|
+
encodeError: (error) =>
|
|
288
|
+
encoder.encode(`data: ${JSON.stringify(runErrorChunk(error))}\n\n`),
|
|
289
|
+
}
|
|
290
|
+
}
|
|
55
291
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
292
|
+
/** Default number of chunks buffered before a durability `append`. */
|
|
293
|
+
const DEFAULT_DURABILITY_BATCH = 32
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Resolve and validate the durability batch size. A non-positive-integer (0,
|
|
297
|
+
* negative, fractional, or `NaN`) is rejected rather than clamped: silently
|
|
298
|
+
* `Math.max(1, …)`-ing a `NaN` used to disable size-based flushing entirely
|
|
299
|
+
* (`length >= NaN` is always false), which is a subtle footgun.
|
|
300
|
+
*/
|
|
301
|
+
function resolveBatchSize(batch: number | undefined): number {
|
|
302
|
+
if (batch === undefined) return DEFAULT_DURABILITY_BATCH
|
|
303
|
+
if (!Number.isInteger(batch) || batch <= 0) {
|
|
304
|
+
throw new Error(
|
|
305
|
+
`Invalid durability batch size: ${batch}. Must be a positive integer.`,
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
return batch
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Boundaries at which the batching producer flushes early, regardless of the
|
|
313
|
+
* batch size — the run-start marker, terminal events, and tool-call ends.
|
|
314
|
+
* Flushing here keeps the durability log promptly consistent at semantically
|
|
315
|
+
* meaningful points.
|
|
316
|
+
*
|
|
317
|
+
* `RUN_STARTED` matters especially for one-shot activities (image, speech,
|
|
318
|
+
* transcription, summarize): they emit `RUN_STARTED`, then await the provider
|
|
319
|
+
* for seconds, then a terminal. Without flushing `RUN_STARTED` the log stays
|
|
320
|
+
* empty for the whole run, so a mount-time `joinRun` finds nothing and its
|
|
321
|
+
* empty-log deadline fast-fails as "run gone" — even though the run is alive.
|
|
322
|
+
* Flushing it immediately makes the run resumable from the instant it starts.
|
|
323
|
+
*/
|
|
324
|
+
function isDurabilityFlushBoundary(chunk: StreamChunk): boolean {
|
|
325
|
+
return (
|
|
326
|
+
chunk.type === 'RUN_STARTED' ||
|
|
327
|
+
chunk.type === 'RUN_FINISHED' ||
|
|
328
|
+
chunk.type === 'RUN_ERROR' ||
|
|
329
|
+
chunk.type === 'TOOL_CALL_END'
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Name of the synthetic `CUSTOM` chunk a fresh durable producer appends to its
|
|
335
|
+
* log before pulling the first real chunk.
|
|
336
|
+
*
|
|
337
|
+
* Flushing `RUN_STARTED` (above) makes a run joinable from the instant the
|
|
338
|
+
* stream EMITS something — but a `chat()` whose middleware boots a sandbox
|
|
339
|
+
* (create a container, install a CLI) legitimately emits nothing for minutes,
|
|
340
|
+
* and during that window the log is empty. Every joiner's empty-log fail-fast
|
|
341
|
+
* (`memoryStream`'s first-chunk deadline, the client's rejoin connect deadline)
|
|
342
|
+
* then reads the run as gone — and the client clears its resume pointer, so a
|
|
343
|
+
* reload during the boot window permanently orphans a run that is still going.
|
|
344
|
+
*
|
|
345
|
+
* This marker closes the window: it is appended (and flushed) before the
|
|
346
|
+
* producer stream is first pulled, so a join always finds a first chunk within
|
|
347
|
+
* milliseconds of the run being accepted. Takeover alignment is unaffected — a
|
|
348
|
+
* journal replay cannot reproduce the marker, and alignment already skips
|
|
349
|
+
* stored `CUSTOM` chunks as out-of-band for exactly that reason (see
|
|
350
|
+
* `isBridgeCustomChunk` in `@tanstack/ai-sandbox`).
|
|
351
|
+
*/
|
|
352
|
+
export const RUN_ACCEPTED_EVENT = 'run.accepted'
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Build the delivery-durable source iterable for a transport helper.
|
|
356
|
+
*
|
|
357
|
+
* - **Resume** (`resumeFrom()` non-null): replay strictly after the offset,
|
|
358
|
+
* reading only from the durability log. The input `stream` is NEVER iterated,
|
|
359
|
+
* so `chat()`'s lazy iterator never fires the provider — the untouched
|
|
360
|
+
* generator is simply GC'd. This is what makes resume free of re-invocation.
|
|
361
|
+
* - **Fresh** (`resumeFrom()` null): iterate `stream`, buffering up to `batch`
|
|
362
|
+
* chunks (flushing early at terminal / tool-call boundaries), `append` each
|
|
363
|
+
* batch to the log, then forward. Appending BEFORE forwarding guarantees a
|
|
364
|
+
* reconnecting client can always replay exactly what it already saw.
|
|
365
|
+
*
|
|
366
|
+
* The returned `getId` maps each forwarded chunk to the exact opaque offset
|
|
367
|
+
* returned by the durability adapter for the SSE `id:` line.
|
|
368
|
+
*/
|
|
369
|
+
function durableStreamSource<TOffset extends string>(
|
|
370
|
+
stream: AsyncIterable<StreamChunk>,
|
|
371
|
+
durability: StreamDurability<TOffset>,
|
|
372
|
+
options: {
|
|
373
|
+
abortController: AbortController
|
|
374
|
+
batch?: number
|
|
375
|
+
logger?: InternalLogger
|
|
376
|
+
},
|
|
377
|
+
): {
|
|
378
|
+
source: AsyncIterable<StreamChunk>
|
|
379
|
+
getId: (chunk: StreamChunk) => string | undefined
|
|
380
|
+
} {
|
|
381
|
+
const resumeOffset = durability.resumeFrom()
|
|
382
|
+
const batchSize = resolveBatchSize(options.batch)
|
|
383
|
+
const abortController = options.abortController
|
|
384
|
+
const logger = options.logger
|
|
385
|
+
const idByChunk = new WeakMap<object, string>()
|
|
386
|
+
const seenOffsets = new Set<string>()
|
|
387
|
+
const getId = (chunk: StreamChunk): string | undefined => idByChunk.get(chunk)
|
|
388
|
+
|
|
389
|
+
const validateOffset = (offset: TOffset): void => {
|
|
390
|
+
// Reject NUL/CR/LF (would corrupt the SSE `id:` line) and any offset that
|
|
391
|
+
// is not invariant under the wire round-trip. The SSE client reads the id
|
|
392
|
+
// with `.trim()`, so an offset with leading/trailing whitespace would come
|
|
393
|
+
// back changed and no longer match on reconnect — fail loud here rather
|
|
394
|
+
// than silently mis-resuming. (NDJSON carries the offset inside the JSON
|
|
395
|
+
// envelope and is unaffected, but the contract must hold for both wires.)
|
|
396
|
+
if (
|
|
397
|
+
offset.length === 0 ||
|
|
398
|
+
offset.includes('\0') ||
|
|
399
|
+
offset.includes('\r') ||
|
|
400
|
+
offset.includes('\n') ||
|
|
401
|
+
offset !== offset.trim()
|
|
402
|
+
) {
|
|
403
|
+
throw new Error(
|
|
404
|
+
`Invalid durability offset for SSE id: ${JSON.stringify(offset)}`,
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
if (seenOffsets.has(offset)) {
|
|
408
|
+
throw new Error(
|
|
409
|
+
`Durability adapter must return a unique offset per chunk: ${JSON.stringify(offset)}`,
|
|
410
|
+
)
|
|
411
|
+
}
|
|
412
|
+
seenOffsets.add(offset)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function* produce(): AsyncIterable<StreamChunk> {
|
|
416
|
+
let batch: Array<StreamChunk> = []
|
|
417
|
+
let terminalPersisted = false
|
|
418
|
+
// Whether a terminal event was actually delivered LIVE to the consumer (as
|
|
419
|
+
// opposed to only appended to the log). Distinguishes "the run already ended
|
|
420
|
+
// on the wire" from "the log has a terminal but the consumer never saw one",
|
|
421
|
+
// which governs whether a late durability-cleanup failure may be rethrown.
|
|
422
|
+
// Only ever assigned inside the nested flush() closure, which TS's
|
|
423
|
+
// control-flow analysis can't observe (see the disable at the read site).
|
|
424
|
+
let terminalForwarded = false
|
|
425
|
+
let failure: RecordedFailure | undefined
|
|
426
|
+
let terminalCause: unknown
|
|
427
|
+
let hasTerminalCause = false
|
|
428
|
+
|
|
429
|
+
const recordFailure = (error: unknown, phase: string): void => {
|
|
430
|
+
failure = {
|
|
431
|
+
error:
|
|
432
|
+
failure === undefined
|
|
433
|
+
? error
|
|
434
|
+
: combineFailures(failure.error, error, phase),
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function* flush(): AsyncIterable<StreamChunk> {
|
|
439
|
+
if (batch.length === 0) return
|
|
440
|
+
const toForward = batch
|
|
441
|
+
batch = []
|
|
442
|
+
// Tag each chunk with the exact backend offset. Requiring one opaque
|
|
443
|
+
// token per chunk preserves exact-once resume at any batch size.
|
|
444
|
+
const offsets = await durability.append(toForward)
|
|
445
|
+
if (offsets.length !== toForward.length) {
|
|
446
|
+
throw new Error(
|
|
447
|
+
`Durability append returned ${offsets.length} offsets for ${toForward.length} chunks`,
|
|
448
|
+
)
|
|
449
|
+
}
|
|
450
|
+
toForward.forEach((chunk, i) => {
|
|
451
|
+
const offset = offsets[i]
|
|
452
|
+
if (offset === undefined) {
|
|
453
|
+
throw new Error(`Durability append omitted offset at index ${i}`)
|
|
454
|
+
}
|
|
455
|
+
validateOffset(offset)
|
|
456
|
+
idByChunk.set(chunk, offset)
|
|
457
|
+
})
|
|
458
|
+
if (
|
|
459
|
+
toForward.some(
|
|
460
|
+
(chunk) =>
|
|
461
|
+
chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR',
|
|
462
|
+
)
|
|
463
|
+
) {
|
|
464
|
+
terminalPersisted = true
|
|
465
|
+
}
|
|
466
|
+
for (const chunk of toForward) {
|
|
467
|
+
if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') {
|
|
468
|
+
terminalForwarded = true
|
|
469
|
+
}
|
|
470
|
+
yield chunk
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
try {
|
|
475
|
+
if (isAborted(abortController.signal)) return
|
|
476
|
+
// Make the run joinable BEFORE the producer is first pulled — the pull
|
|
477
|
+
// is what runs the middleware chain, and middleware may take minutes to
|
|
478
|
+
// yield a first chunk. See {@link RUN_ACCEPTED_EVENT}.
|
|
479
|
+
batch.push({
|
|
480
|
+
type: 'CUSTOM',
|
|
481
|
+
name: RUN_ACCEPTED_EVENT,
|
|
482
|
+
value: {},
|
|
483
|
+
timestamp: Date.now(),
|
|
484
|
+
})
|
|
485
|
+
yield* flush()
|
|
486
|
+
for await (const chunk of stream) {
|
|
487
|
+
if (isAborted(abortController.signal)) break
|
|
488
|
+
batch.push(chunk)
|
|
489
|
+
if (batch.length >= batchSize || isDurabilityFlushBoundary(chunk)) {
|
|
490
|
+
yield* flush()
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (!isAborted(abortController.signal)) yield* flush()
|
|
494
|
+
} catch (error) {
|
|
495
|
+
terminalCause = error
|
|
496
|
+
hasTerminalCause = true
|
|
497
|
+
recordFailure(error, 'producer failed')
|
|
498
|
+
// The provider stream threw. Persist a terminal RUN_ERROR to the
|
|
499
|
+
// durability log so a resumer / joiner learns the run failed (otherwise
|
|
500
|
+
// the log ends with no terminal and they wait forever). Flush any
|
|
501
|
+
// buffered chunks first, then append the terminal WITHOUT forwarding it
|
|
502
|
+
// live — the transport layer synthesizes the live RUN_ERROR on rethrow,
|
|
503
|
+
// so forwarding here too would double-emit.
|
|
504
|
+
if (!isAborted(abortController.signal)) {
|
|
505
|
+
try {
|
|
506
|
+
yield* flush()
|
|
507
|
+
} catch (flushError) {
|
|
508
|
+
recordFailure(flushError, 'flushing buffered chunks failed')
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
} finally {
|
|
512
|
+
// The PRODUCER was stopped, which is deliberately not the same question as
|
|
513
|
+
// "did the delivery socket go away". A disconnect alone must leave this
|
|
514
|
+
// false: the run survives it and terminalizes this log itself on its way
|
|
515
|
+
// out, and treating the disconnect as a cancel here would make `detached`
|
|
516
|
+
// true for a run that had already finished — skipping `close()` and parking
|
|
517
|
+
// every later tailer forever on a log nobody will ever continue.
|
|
518
|
+
const cancelled = isAborted(abortController.signal)
|
|
519
|
+
|
|
520
|
+
// Persist any buffered-but-unflushed chunks before terminalizing, so a
|
|
521
|
+
// joiner replaying the log sees everything produced up to a disconnect
|
|
522
|
+
// rather than a truncated prefix. On the abort path the streaming loop
|
|
523
|
+
// broke before its trailing flush; drain flush() here for its persistence
|
|
524
|
+
// side effect only (the delivery socket is gone, so the yielded chunks are
|
|
525
|
+
// discarded). The normal and provider-throw paths already flushed, so
|
|
526
|
+
// `batch` is empty for them and this is a no-op.
|
|
527
|
+
if (batch.length > 0) {
|
|
528
|
+
try {
|
|
529
|
+
for await (const _chunk of flush()) {
|
|
530
|
+
// persist-only: nothing consumes these
|
|
63
531
|
}
|
|
532
|
+
} catch (flushError) {
|
|
533
|
+
recordFailure(flushError, 'flushing buffered chunks on exit failed')
|
|
534
|
+
}
|
|
535
|
+
}
|
|
64
536
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
537
|
+
// Was this abort a DETACH? Only the run's own middleware can say — it is
|
|
538
|
+
// the only actor that has resolved both out-of-band cancel bands and
|
|
539
|
+
// `detachOnDisconnect` — and it says so on the stream itself (see
|
|
540
|
+
// `./delivery-detach`). Read only AFTER the try block above has exited,
|
|
541
|
+
// which is what awaits the chat generator's `return()` and therefore the
|
|
542
|
+
// whole `onAbort` chain that publishes the verdict.
|
|
543
|
+
//
|
|
544
|
+
// Every conjunct is load bearing. `cancelled` keeps a normal finish on
|
|
545
|
+
// today's path. `!isExplicitCancel` is core's own belt-and-braces refusal to
|
|
546
|
+
// spare a run the user deliberately stopped, whatever a middleware claims.
|
|
547
|
+
// `!hasTerminalCause` keeps a GENUINE provider failure
|
|
548
|
+
// terminal even if the socket died too, so a real error is never mistaken
|
|
549
|
+
// for a detach. And `wasRunDetached` is false for an
|
|
550
|
+
// explicit cancel (either band), for a non-detachable disconnect, and for
|
|
551
|
+
// every app that has not wired durability — all of which keep terminalizing
|
|
552
|
+
// and closing exactly as before.
|
|
553
|
+
//
|
|
554
|
+
// What is ALREADY IN THE LOG is deliberately NOT a conjunct. An agent-loop
|
|
555
|
+
// run emits one `RUN_FINISHED` PER ITERATION — the intermediate
|
|
556
|
+
// `finishReason: 'tool_calls'` terminal is flushed at its boundary
|
|
557
|
+
// mid-run — so `terminalPersisted` means "some terminal is in the log",
|
|
558
|
+
// never "the run ended". Gating on it terminalized the log of a healthy,
|
|
559
|
+
// still-running agent for every tool-calling run. Nor can the sink
|
|
560
|
+
// distinguish a final terminal from an intermediate one by its
|
|
561
|
+
// `finishReason`: only the run's middleware knows, and that is exactly
|
|
562
|
+
// what the verdict reports. So a published detach verdict WINS — it
|
|
563
|
+
// already means "the agent is alive and a successor will terminalize this
|
|
564
|
+
// log".
|
|
565
|
+
const detached =
|
|
566
|
+
cancelled &&
|
|
567
|
+
!isExplicitCancel(abortController.signal) &&
|
|
568
|
+
!hasTerminalCause &&
|
|
569
|
+
wasRunDetached(stream)
|
|
570
|
+
|
|
571
|
+
if (
|
|
572
|
+
!detached &&
|
|
573
|
+
needsTerminalPersistence(terminalPersisted, cancelled, hasTerminalCause)
|
|
574
|
+
) {
|
|
575
|
+
// Prefer the real provider error even when the delivery socket was also
|
|
576
|
+
// aborted: if the run genuinely failed, a joiner should see that cause,
|
|
577
|
+
// not a generic AbortError that masks it. AbortError is only used for a
|
|
578
|
+
// pure cancellation with no underlying failure.
|
|
579
|
+
const cause = hasTerminalCause ? terminalCause : { name: 'AbortError' }
|
|
580
|
+
try {
|
|
581
|
+
await durability.append([runErrorChunk(cause)])
|
|
582
|
+
terminalPersisted = true
|
|
583
|
+
} catch (terminalError) {
|
|
584
|
+
// Rethrown to the live consumer below, but a joiner replaying the log
|
|
585
|
+
// only ever sees a generic incomplete error — so record the real
|
|
586
|
+
// cause server-side where an operator can act on it.
|
|
587
|
+
logger?.errors('persisting terminal RUN_ERROR failed', {
|
|
588
|
+
error: terminalError,
|
|
589
|
+
})
|
|
590
|
+
recordFailure(terminalError, 'persisting terminal RUN_ERROR failed')
|
|
69
591
|
}
|
|
592
|
+
}
|
|
70
593
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
594
|
+
// A detached run's log is deliberately left OPEN: the run is still going,
|
|
595
|
+
// and `close()` would terminalize the log the takeover has to continue —
|
|
596
|
+
// a tailing attach would stop at the prefix, and a stored synthetic
|
|
597
|
+
// `RUN_ERROR` would additionally diverge the takeover's journal replay and
|
|
598
|
+
// record a healthy run as failed.
|
|
599
|
+
//
|
|
600
|
+
// This is NOT the general "fence the close" that `ai-sandbox`'s claim.ts
|
|
601
|
+
// rules out. That fence would suppress `close()` for a run nobody will ever
|
|
602
|
+
// drive again, wedging the record at `'running'` with every tailer parked
|
|
603
|
+
// forever. The skip here is conditional on a verdict that means the exact
|
|
604
|
+
// opposite: the agent is alive and a successor WILL terminalize this log
|
|
605
|
+
// (its own producer exit runs this same `finally`). Keep that distinction —
|
|
606
|
+
// widening this condition to "any abort" re-introduces the wedge.
|
|
607
|
+
if (!detached) {
|
|
608
|
+
try {
|
|
609
|
+
await durability.close()
|
|
610
|
+
} catch (closeError) {
|
|
611
|
+
// A failed close leaves the durable log unterminated for joiners; the
|
|
612
|
+
// live consumer gets the rethrow, but log it for the joiner's sake.
|
|
613
|
+
logger?.errors('closing durability stream failed', {
|
|
614
|
+
error: closeError,
|
|
615
|
+
})
|
|
616
|
+
recordFailure(closeError, 'closing durability stream failed')
|
|
77
617
|
}
|
|
618
|
+
}
|
|
78
619
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
620
|
+
// Rethrow a terminalization/close failure to the live consumer ONLY when
|
|
621
|
+
// no terminal reached it yet — the transport then synthesizes a live
|
|
622
|
+
// RUN_ERROR so the consumer isn't left without a terminal. If a terminal
|
|
623
|
+
// was already forwarded (the run ended on the wire), a late failure is a
|
|
624
|
+
// server-side cleanup issue; rethrowing it would append a contradictory
|
|
625
|
+
// second terminal (RUN_ERROR after RUN_FINISHED) on the wire. Suppress the
|
|
626
|
+
// rethrow, but never let the cause vanish — record it server-side, the
|
|
627
|
+
// same as the close / terminal-append failures above. (This also covers a
|
|
628
|
+
// provider that throws AFTER emitting its own terminal, whose error is
|
|
629
|
+
// otherwise neither delivered nor logged.)
|
|
630
|
+
if (failure !== undefined) {
|
|
631
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- terminalForwarded is set only inside the flush() closure, which TS CFA narrows away here
|
|
632
|
+
if (!terminalForwarded) {
|
|
633
|
+
// eslint-disable-next-line no-unsafe-finally
|
|
634
|
+
throw failure.error
|
|
635
|
+
}
|
|
636
|
+
logger?.errors(
|
|
637
|
+
'durability failure after a terminal event was forwarded',
|
|
638
|
+
{
|
|
639
|
+
error: failure.error,
|
|
640
|
+
},
|
|
88
641
|
)
|
|
89
|
-
controller.close()
|
|
90
|
-
}
|
|
91
|
-
},
|
|
92
|
-
cancel() {
|
|
93
|
-
// When the ReadableStream is cancelled (e.g., client disconnects),
|
|
94
|
-
// abort the underlying stream
|
|
95
|
-
if (abortController) {
|
|
96
|
-
abortController.abort()
|
|
97
642
|
}
|
|
98
|
-
}
|
|
99
|
-
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
async function* replay(offset: TOffset): AsyncIterable<StreamChunk> {
|
|
647
|
+
// Thread the consumer's abort signal into the read so a live-tailing join
|
|
648
|
+
// (a mid-stream reconnect) that is aborted — or that hit a runId with no
|
|
649
|
+
// in-process producer — stops parking and ends instead of hanging forever.
|
|
650
|
+
for await (const { offset: eventOffset, chunk } of durability.read(
|
|
651
|
+
offset,
|
|
652
|
+
abortController.signal,
|
|
653
|
+
)) {
|
|
654
|
+
if (isAborted(abortController.signal)) break
|
|
655
|
+
validateOffset(eventOffset)
|
|
656
|
+
idByChunk.set(chunk, eventOffset)
|
|
657
|
+
yield chunk
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
return {
|
|
662
|
+
source: resumeOffset !== null ? replay(resumeOffset) : produce(),
|
|
663
|
+
getId,
|
|
664
|
+
}
|
|
100
665
|
}
|
|
101
666
|
|
|
102
667
|
/**
|
|
@@ -107,21 +672,42 @@ export function toServerSentEventsStream(
|
|
|
107
672
|
* - Each chunk is followed by "\n\n"
|
|
108
673
|
* - Stream ends when the underlying iterable is exhausted (RUN_FINISHED is the terminal event)
|
|
109
674
|
*
|
|
675
|
+
* Pass a `durability` sink (`memoryStream(request)` / `durableStream(request)`)
|
|
676
|
+
* to make the stream resumable: fresh runs are appended to the log and each SSE
|
|
677
|
+
* event is tagged with an `id:` offset; a reconnect (native `Last-Event-ID`) or
|
|
678
|
+
* a `?offset` join replays from the log without re-running the producer. `batch`
|
|
679
|
+
* controls how many chunks are buffered per `append` (default 32).
|
|
680
|
+
*
|
|
110
681
|
* @param stream - AsyncIterable of StreamChunks from chat()
|
|
111
|
-
* @param init - Optional Response initialization options (including `abortController`)
|
|
682
|
+
* @param init - Optional Response initialization options (including `abortController`, `durability` with its optional `batch`, and `debug`)
|
|
112
683
|
* @returns Response in Server-Sent Events format
|
|
113
684
|
*
|
|
114
685
|
* @example
|
|
115
686
|
* ```typescript
|
|
116
|
-
*
|
|
117
|
-
*
|
|
687
|
+
* export async function POST(request: Request) {
|
|
688
|
+
* const stream = chat({ adapter: openaiText('gpt-5.5'), messages: [...] });
|
|
689
|
+
* return toServerSentEventsResponse(stream, { durability: { adapter: memoryStream(request) } });
|
|
690
|
+
* }
|
|
118
691
|
* ```
|
|
119
692
|
*/
|
|
120
|
-
export function toServerSentEventsResponse(
|
|
693
|
+
export function toServerSentEventsResponse<TOffset extends string = string>(
|
|
121
694
|
stream: AsyncIterable<StreamChunk>,
|
|
122
|
-
init?: ResponseInit & {
|
|
695
|
+
init?: ResponseInit & {
|
|
696
|
+
abortController?: AbortController
|
|
697
|
+
durability?: { adapter: StreamDurability<TOffset>; batch?: number }
|
|
698
|
+
/**
|
|
699
|
+
* Customize logging for durability failure paths (terminal-append and
|
|
700
|
+
* close). These failures are always logged server-side by default (the
|
|
701
|
+
* `errors` category is on even without `debug`, via a `ConsoleLogger`);
|
|
702
|
+
* pass `debug` to route them to a custom `Logger` or raise verbosity. A
|
|
703
|
+
* joiner replaying the log only ever sees a generic incomplete error, so
|
|
704
|
+
* server-side logging is where the real cause is recoverable.
|
|
705
|
+
*/
|
|
706
|
+
debug?: DebugOption
|
|
707
|
+
},
|
|
123
708
|
): Response {
|
|
124
|
-
const { headers, abortController, ...responseInit } =
|
|
709
|
+
const { headers, abortController, durability, debug, ...responseInit } =
|
|
710
|
+
init ?? {}
|
|
125
711
|
|
|
126
712
|
// Start with default SSE headers
|
|
127
713
|
const mergedHeaders = new Headers({
|
|
@@ -139,12 +725,292 @@ export function toServerSentEventsResponse(
|
|
|
139
725
|
})
|
|
140
726
|
}
|
|
141
727
|
|
|
142
|
-
|
|
728
|
+
let body: ReadableStream<Uint8Array>
|
|
729
|
+
if (durability) {
|
|
730
|
+
// A fresh run (not a resume/replay) drains into the durable log under its
|
|
731
|
+
// OWN producer controller, decoupled from the HTTP response: a response
|
|
732
|
+
// cancel (page reload) detaches and keeps draining in the background so a
|
|
733
|
+
// rejoining client tails the log to the real terminal, rather than killing
|
|
734
|
+
// the run and sealing the log with RUN_ERROR. The producer is aborted only
|
|
735
|
+
// by a caller-supplied `abortController` (a genuine stop()). On the resume
|
|
736
|
+
// path the response IS a reader, so a cancel should stop the read normally.
|
|
737
|
+
const isFresh = durability.adapter.resumeFrom() === null
|
|
738
|
+
const producerAbortController = abortController ?? new AbortController()
|
|
739
|
+
const deliveryAbortController = isFresh
|
|
740
|
+
? new AbortController()
|
|
741
|
+
: producerAbortController
|
|
742
|
+
const { source, getId } = durableStreamSource(stream, durability.adapter, {
|
|
743
|
+
abortController: producerAbortController,
|
|
744
|
+
batch: durability.batch,
|
|
745
|
+
// `errors` category is on by default even when `debug` is undefined, so
|
|
746
|
+
// durability terminal-append / close failures always surface server-side —
|
|
747
|
+
// including on the client-disconnect path where there is no live consumer.
|
|
748
|
+
logger: resolveDebugOption(debug),
|
|
749
|
+
})
|
|
750
|
+
const { encodeChunk, encodeError } = sseEncoders(getId)
|
|
751
|
+
body = toEncodedStream(
|
|
752
|
+
source,
|
|
753
|
+
deliveryAbortController,
|
|
754
|
+
encodeChunk,
|
|
755
|
+
encodeError,
|
|
756
|
+
isFresh,
|
|
757
|
+
// Fresh runs only: a resume response IS a reader, so its cancel is an
|
|
758
|
+
// ordinary read being stopped, not a producer losing its viewer.
|
|
759
|
+
isFresh ? () => notifyRunDisconnected(stream) : undefined,
|
|
760
|
+
)
|
|
761
|
+
} else {
|
|
762
|
+
body = toServerSentEventsStream(stream, abortController)
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
return new Response(body, {
|
|
143
766
|
...responseInit,
|
|
144
767
|
headers: mergedHeaders,
|
|
145
768
|
})
|
|
146
769
|
}
|
|
147
770
|
|
|
771
|
+
/**
|
|
772
|
+
* A resume is served entirely from the durability log, so there is no producer
|
|
773
|
+
* to iterate. This empty source satisfies the response helpers' signature; on a
|
|
774
|
+
* resume `durableStreamSource` replays from the log and never touches it.
|
|
775
|
+
*/
|
|
776
|
+
function emptyDurableSource(): AsyncIterable<StreamChunk> {
|
|
777
|
+
return (async function* () {})()
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Everything the resume helpers need to take a run over as a side effect of
|
|
782
|
+
* serving its log.
|
|
783
|
+
*
|
|
784
|
+
* `claim` and `pipe` are **injected**, not imported. The two mechanisms a
|
|
785
|
+
* takeover needs (`withRunClaim` and `pipeToRunLog`) live in
|
|
786
|
+
* `@tanstack/ai-sandbox`, and `@tanstack/ai` must not depend on that package —
|
|
787
|
+
* that layering inversion is exactly what moving `LockStore` into core was meant
|
|
788
|
+
* to prevent, and it would make core depend on the sandbox package to serve a
|
|
789
|
+
* plain chat run. Injecting them keeps only the *shape* of a takeover in core
|
|
790
|
+
* (parse the run id, read the record, skip if terminal, claim, drive) and lets a
|
|
791
|
+
* background-worker-driven run supply its own pair.
|
|
792
|
+
* `@tanstack/ai-sandbox`'s `sandboxRunDriver` fills both in.
|
|
793
|
+
*/
|
|
794
|
+
export interface RunDriverOptions {
|
|
795
|
+
/** The attach request; its run id is read with {@link resolveResumeRunId}. */
|
|
796
|
+
request: Request
|
|
797
|
+
runs: RunStore
|
|
798
|
+
locks: LockStore
|
|
799
|
+
/** Produce the run's remaining events. Called only once the claim is held. */
|
|
800
|
+
drive: (input: {
|
|
801
|
+
runId: string
|
|
802
|
+
threadId: string
|
|
803
|
+
signal: AbortSignal
|
|
804
|
+
}) => AsyncIterable<StreamChunk>
|
|
805
|
+
/** Run `fn` under exclusive ownership of the run, or reject if refused. */
|
|
806
|
+
claim: <T>(
|
|
807
|
+
input: { runs: RunStore; locks: LockStore; runId: string },
|
|
808
|
+
fn: (claim: {
|
|
809
|
+
runId: string
|
|
810
|
+
epoch: number
|
|
811
|
+
signal: AbortSignal
|
|
812
|
+
}) => Promise<T>,
|
|
813
|
+
) => Promise<T>
|
|
814
|
+
/** Persist the driven stream to the run's producer-side durability log. */
|
|
815
|
+
pipe: (
|
|
816
|
+
stream: AsyncIterable<StreamChunk>,
|
|
817
|
+
input: { runId: string; threadId: string; signal: AbortSignal },
|
|
818
|
+
) => Promise<unknown>
|
|
819
|
+
/** Platform keep-alive (e.g. `ctx.waitUntil`) for the background drive. */
|
|
820
|
+
waitUntil?: (promise: Promise<unknown>) => void
|
|
821
|
+
logger?: InternalLogger
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/** Shared options for the resume-only response helpers. */
|
|
825
|
+
type ResumeResponseOptions<TOffset extends string> = ResponseInit & {
|
|
826
|
+
adapter: StreamDurability<TOffset>
|
|
827
|
+
batch?: number
|
|
828
|
+
debug?: DebugOption
|
|
829
|
+
/**
|
|
830
|
+
* Take the run over while serving its log. Omit to serve the log only —
|
|
831
|
+
* the response is byte-identical either way.
|
|
832
|
+
*/
|
|
833
|
+
driver?: RunDriverOptions
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Take over an in-flight run as a side effect of serving its log.
|
|
838
|
+
*
|
|
839
|
+
* The response itself is unchanged: it still replays from the durability log via
|
|
840
|
+
* `emptyDurableSource()`. The drive runs BESIDE it, appending to the run's own
|
|
841
|
+
* producer-side log through the injected `pipe`, and the response tails what
|
|
842
|
+
* lands. That separation is what lets a taken-over run keep `chat()`'s normal
|
|
843
|
+
* middleware path — `withPersistence.onFinish` is what saves the transcript, so a
|
|
844
|
+
* parallel translation path would lose the history of any run that completed
|
|
845
|
+
* while detached.
|
|
846
|
+
*
|
|
847
|
+
* TOTAL BY CONSTRUCTION. Every failure is logged and swallowed:
|
|
848
|
+
*
|
|
849
|
+
* - No run id, no record, or a terminal record → serve the log, drive nothing.
|
|
850
|
+
* A second tab attaching to a finished run must still see the transcript.
|
|
851
|
+
* - The claim is refused (another host is already driving) → serve the log,
|
|
852
|
+
* drive nothing. That is the documented "two hosts attach at once: one wins
|
|
853
|
+
* the lease and drives, the other tails the log" behavior.
|
|
854
|
+
* - The drive throws → logged. It cannot be reported to this response, which is
|
|
855
|
+
* already streaming the log; the run's own `RUN_ERROR` event is the channel.
|
|
856
|
+
*
|
|
857
|
+
* A rejection escaping here would be an unhandled rejection with nobody to
|
|
858
|
+
* report it to — process-fatal on modern Node and instance-fatal inside a
|
|
859
|
+
* Durable Object.
|
|
860
|
+
*/
|
|
861
|
+
function startRunDriver(driver: RunDriverOptions): void {
|
|
862
|
+
const logger = driver.logger
|
|
863
|
+
const promise = (async () => {
|
|
864
|
+
const runId = resolveResumeRunId(driver.request)
|
|
865
|
+
if (runId === null) return
|
|
866
|
+
let record: RunRecord | null = null
|
|
867
|
+
try {
|
|
868
|
+
record = await driver.runs.get(runId)
|
|
869
|
+
} catch (error) {
|
|
870
|
+
logger?.errors('resume driver: reading the run record failed', {
|
|
871
|
+
runId,
|
|
872
|
+
error,
|
|
873
|
+
})
|
|
874
|
+
return
|
|
875
|
+
}
|
|
876
|
+
// Validated, not trusted: `record.status` is typed `RunStatus` but comes off
|
|
877
|
+
// a user-implemented `RunStore`, so the type is a claim about a storage
|
|
878
|
+
// column and nothing checked it. An unrecognized value means the run cannot
|
|
879
|
+
// be reasoned about at all — the record says nothing trustworthy about
|
|
880
|
+
// whether an agent is already driving it — so refuse the drive the same way
|
|
881
|
+
// a terminal record does, and still serve the log so a corrupt row does not
|
|
882
|
+
// also blank the transcript.
|
|
883
|
+
if (record !== null && !isRunStatus(record.status)) {
|
|
884
|
+
logger?.errors(
|
|
885
|
+
'resume driver: the run record has an unrecognized status',
|
|
886
|
+
{
|
|
887
|
+
runId,
|
|
888
|
+
status: record.status,
|
|
889
|
+
},
|
|
890
|
+
)
|
|
891
|
+
return
|
|
892
|
+
}
|
|
893
|
+
if (record === null || isTerminalRunStatus(record.status)) return
|
|
894
|
+
// A recorded cancel is NOT a status. `requestRunCancel` deliberately writes
|
|
895
|
+
// only `cancelRequested`, so a run cancelled out of band while its driving
|
|
896
|
+
// host had already died stays `'running'` — and the status gate above waves
|
|
897
|
+
// it straight through. Driving it resurrects a run the user explicitly
|
|
898
|
+
// stopped and burns tokens until the TTL expires. The log is still served, so
|
|
899
|
+
// an attaching tab sees the transcript; only the drive is refused.
|
|
900
|
+
//
|
|
901
|
+
// This is the "don't START one" half. Aborting a drive that is ALREADY live
|
|
902
|
+
// when a cancel lands afterwards is a separate, still-open concern.
|
|
903
|
+
if (record.cancelRequested === true) return
|
|
904
|
+
// Captured after narrowing so the closure below sees a definite record
|
|
905
|
+
// rather than the re-widened `let`.
|
|
906
|
+
const active = record
|
|
907
|
+
|
|
908
|
+
try {
|
|
909
|
+
await driver.claim(
|
|
910
|
+
{ runs: driver.runs, locks: driver.locks, runId },
|
|
911
|
+
async (claim) => {
|
|
912
|
+
// A viewer is attached again, so the detached clock stops. Cleared
|
|
913
|
+
// under the claim so it cannot race the reaper's read.
|
|
914
|
+
//
|
|
915
|
+
// THE REAPER: do NOT reuse `startRunDriver` for reclaiming detached
|
|
916
|
+
// runs. `@tanstack/ai-sandbox`'s `reapDetachedRuns` deliberately does
|
|
917
|
+
// the opposite of this line — it ACTS ON `detachedSince` and must
|
|
918
|
+
// leave the marker intact for its own TTL accounting — so borrowing
|
|
919
|
+
// this path would erase the very evidence the reaper selected the run
|
|
920
|
+
// on, resetting the TTL on every sweep so a detached run could never
|
|
921
|
+
// expire. That is why the reaper has its own drive path.
|
|
922
|
+
//
|
|
923
|
+
// LOG AND CONTINUE. This write is BOOKKEEPING for the reaper's TTL
|
|
924
|
+
// accounting; the claim is already held and the takeover is the
|
|
925
|
+
// valuable part. Letting a rejection propagate would land in the catch
|
|
926
|
+
// below — the channel reserved for the normal "someone else won the
|
|
927
|
+
// lease" case — so one transient store error would silently cost the
|
|
928
|
+
// whole drive, logged only on the `provider` debug channel and
|
|
929
|
+
// therefore invisible at default log levels. The worst case of
|
|
930
|
+
// continuing is a stale `detachedSince` the reaper may act on later;
|
|
931
|
+
// the worst case of vetoing is a run nobody drives at all.
|
|
932
|
+
try {
|
|
933
|
+
await driver.runs.update(runId, { detachedSince: undefined })
|
|
934
|
+
} catch (error) {
|
|
935
|
+
logger?.errors('resume driver: clearing detachedSince failed', {
|
|
936
|
+
runId,
|
|
937
|
+
error,
|
|
938
|
+
})
|
|
939
|
+
}
|
|
940
|
+
await driver.pipe(
|
|
941
|
+
driver.drive({
|
|
942
|
+
runId,
|
|
943
|
+
threadId: active.threadId,
|
|
944
|
+
signal: claim.signal,
|
|
945
|
+
}),
|
|
946
|
+
{ runId, threadId: active.threadId, signal: claim.signal },
|
|
947
|
+
)
|
|
948
|
+
},
|
|
949
|
+
)
|
|
950
|
+
} catch (error) {
|
|
951
|
+
// Includes RunClaimNotAcquiredError (someone else is driving) and
|
|
952
|
+
// RunClaimLostError (we were superseded mid-drive). Both are normal.
|
|
953
|
+
logger?.provider('resume driver: not driving this run', { runId, error })
|
|
954
|
+
}
|
|
955
|
+
})()
|
|
956
|
+
|
|
957
|
+
if (driver.waitUntil) {
|
|
958
|
+
driver.waitUntil(promise)
|
|
959
|
+
} else {
|
|
960
|
+
// No platform keep-alive: at least ensure the rejection is handled. The
|
|
961
|
+
// async body above already catches everything, so this is belt-and-braces.
|
|
962
|
+
void promise.catch(() => {})
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* The single wiring point both resume helpers call, so the SSE and NDJSON
|
|
968
|
+
* halves cannot drift: a fix here applies to both. Called AFTER each helper's
|
|
969
|
+
* `resumeFrom() === null` 400 check — an attach with no offset has nothing to
|
|
970
|
+
* replay, and driving a run whose response will 400 would start an agent
|
|
971
|
+
* nobody is watching.
|
|
972
|
+
*/
|
|
973
|
+
function maybeStartRunDriver(driver: RunDriverOptions | undefined): void {
|
|
974
|
+
if (driver) startRunDriver(driver)
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const NO_RESUME_OFFSET =
|
|
978
|
+
'No resume offset provided (expected a Last-Event-ID header or an ?offset query parameter).'
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* Serve a resumable run from its durability log over Server-Sent Events, without
|
|
982
|
+
* re-running the model. Use this in a `GET` handler so a reload or a second tab
|
|
983
|
+
* can re-attach to an in-flight or finished run.
|
|
984
|
+
*
|
|
985
|
+
* The adapter (`memoryStream(request)` / `durableStream(request)`) captures the
|
|
986
|
+
* resume offset from the request. If there is none (no `Last-Event-ID` header
|
|
987
|
+
* and no `?offset`), there is nothing to replay and this returns a 400.
|
|
988
|
+
*
|
|
989
|
+
* @example
|
|
990
|
+
* ```typescript
|
|
991
|
+
* export async function GET(request: Request) {
|
|
992
|
+
* return resumeServerSentEventsResponse({ adapter: memoryStream(request) });
|
|
993
|
+
* }
|
|
994
|
+
* ```
|
|
995
|
+
*/
|
|
996
|
+
export function resumeServerSentEventsResponse<TOffset extends string = string>(
|
|
997
|
+
options: ResumeResponseOptions<TOffset>,
|
|
998
|
+
): Response {
|
|
999
|
+
// `driver` MUST be destructured out: `responseInit` is spread into
|
|
1000
|
+
// `new Response(body, init)`, so leaving it in would leak the driver object
|
|
1001
|
+
// (and its Request) into the response init.
|
|
1002
|
+
const { adapter, batch, debug, driver, ...responseInit } = options
|
|
1003
|
+
if (adapter.resumeFrom() === null) {
|
|
1004
|
+
return new Response(NO_RESUME_OFFSET, { status: 400 })
|
|
1005
|
+
}
|
|
1006
|
+
maybeStartRunDriver(driver)
|
|
1007
|
+
return toServerSentEventsResponse(emptyDurableSource(), {
|
|
1008
|
+
...responseInit,
|
|
1009
|
+
durability: { adapter, batch },
|
|
1010
|
+
debug,
|
|
1011
|
+
})
|
|
1012
|
+
}
|
|
1013
|
+
|
|
148
1014
|
/**
|
|
149
1015
|
* Convert a StreamChunk async iterable to a ReadableStream in HTTP stream format (newline-delimited JSON)
|
|
150
1016
|
*
|
|
@@ -154,13 +1020,20 @@ export function toServerSentEventsResponse(
|
|
|
154
1020
|
*
|
|
155
1021
|
* This format is compatible with `fetchHttpStream` connection adapter.
|
|
156
1022
|
*
|
|
1023
|
+
* When `getId` is supplied (delivery durability), each chunk is emitted as an
|
|
1024
|
+
* envelope `{"id":"<offset>","chunk":{…}}` instead of a bare chunk. NDJSON has
|
|
1025
|
+
* no native event-id field like SSE's `id:` line, so the resumable offset rides
|
|
1026
|
+
* inside the payload. Untagged chunks (no id) stay bare, so a non-durable
|
|
1027
|
+
* stream is byte-identical to before and the client auto-detects either form.
|
|
1028
|
+
*
|
|
157
1029
|
* @param stream - AsyncIterable of StreamChunks from chat()
|
|
158
1030
|
* @param abortController - Optional AbortController to abort when stream is cancelled
|
|
1031
|
+
* @param getId - Optional per-chunk durability offset; when present, chunks are envelope-encoded
|
|
159
1032
|
* @returns ReadableStream in HTTP stream format (newline-delimited JSON)
|
|
160
1033
|
*
|
|
161
1034
|
* @example
|
|
162
1035
|
* ```typescript
|
|
163
|
-
* const stream = chat({ adapter: openaiText(
|
|
1036
|
+
* const stream = chat({ adapter: openaiText('gpt-5.5'), messages: [...] });
|
|
164
1037
|
* const readableStream = toHttpStream(stream);
|
|
165
1038
|
* // Use with Response for HTTP streaming (not SSE)
|
|
166
1039
|
* return new Response(readableStream, {
|
|
@@ -171,51 +1044,33 @@ export function toServerSentEventsResponse(
|
|
|
171
1044
|
export function toHttpStream(
|
|
172
1045
|
stream: AsyncIterable<StreamChunk>,
|
|
173
1046
|
abortController?: AbortController,
|
|
1047
|
+
getId?: (chunk: StreamChunk, index: number) => string | undefined,
|
|
174
1048
|
): ReadableStream<Uint8Array> {
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
async start(controller) {
|
|
179
|
-
try {
|
|
180
|
-
for await (const chunk of stream) {
|
|
181
|
-
// Check if stream was cancelled/aborted
|
|
182
|
-
if (abortController?.signal.aborted) {
|
|
183
|
-
break
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Send each chunk as newline-delimited JSON
|
|
187
|
-
controller.enqueue(encoder.encode(`${JSON.stringify(chunk)}\n`))
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
controller.close()
|
|
191
|
-
} catch (error: unknown) {
|
|
192
|
-
// Don't send error if aborted
|
|
193
|
-
if (abortController?.signal.aborted) {
|
|
194
|
-
controller.close()
|
|
195
|
-
return
|
|
196
|
-
}
|
|
1049
|
+
const { encodeChunk, encodeError } = ndjsonEncoders(getId)
|
|
1050
|
+
return toEncodedStream(stream, abortController, encodeChunk, encodeError)
|
|
1051
|
+
}
|
|
197
1052
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
abortController.abort()
|
|
216
|
-
}
|
|
1053
|
+
/**
|
|
1054
|
+
* NDJSON chunk/error encoders. Shared by {@link toHttpStream} and the internal
|
|
1055
|
+
* durability branch (see {@link sseEncoders}).
|
|
1056
|
+
*/
|
|
1057
|
+
function ndjsonEncoders(
|
|
1058
|
+
getId?: (chunk: StreamChunk, index: number) => string | undefined,
|
|
1059
|
+
): {
|
|
1060
|
+
encodeChunk: (chunk: StreamChunk, index: number) => Uint8Array
|
|
1061
|
+
encodeError: (error: unknown) => Uint8Array
|
|
1062
|
+
} {
|
|
1063
|
+
const encoder = new TextEncoder()
|
|
1064
|
+
return {
|
|
1065
|
+
encodeChunk: (chunk, index) => {
|
|
1066
|
+
const id = getId?.(chunk, index)
|
|
1067
|
+
const line =
|
|
1068
|
+
id === undefined ? JSON.stringify(chunk) : JSON.stringify({ id, chunk })
|
|
1069
|
+
return encoder.encode(`${line}\n`)
|
|
217
1070
|
},
|
|
218
|
-
|
|
1071
|
+
encodeError: (error) =>
|
|
1072
|
+
encoder.encode(`${JSON.stringify(runErrorChunk(error))}\n`),
|
|
1073
|
+
}
|
|
219
1074
|
}
|
|
220
1075
|
|
|
221
1076
|
/**
|
|
@@ -227,21 +1082,122 @@ export function toHttpStream(
|
|
|
227
1082
|
*
|
|
228
1083
|
* This format is compatible with `fetchHttpStream` connection adapter.
|
|
229
1084
|
*
|
|
1085
|
+
* Pass a `durability` sink (`memoryStream(request)` / `durableStream(request)`)
|
|
1086
|
+
* to make the stream resumable: fresh runs are appended to the log and each
|
|
1087
|
+
* NDJSON line is emitted as an `{ id, chunk }` envelope carrying an opaque
|
|
1088
|
+
* offset; a reconnect (native `Last-Event-ID` header) or a `?offset` join
|
|
1089
|
+
* replays from the log without re-running the producer. `batch` controls how
|
|
1090
|
+
* many chunks are buffered per `append` (default 32). This shares the exact
|
|
1091
|
+
* `durableStreamSource` used by `toServerSentEventsResponse` — only the wire
|
|
1092
|
+
* encoding differs.
|
|
1093
|
+
*
|
|
230
1094
|
* @param stream - AsyncIterable of StreamChunks from chat()
|
|
231
|
-
* @param init - Optional Response initialization options (including `abortController`)
|
|
1095
|
+
* @param init - Optional Response initialization options (including `abortController`, `durability` with its optional `batch`, and `debug`)
|
|
232
1096
|
* @returns Response in HTTP stream format (newline-delimited JSON)
|
|
233
1097
|
*
|
|
234
1098
|
* @example
|
|
235
1099
|
* ```typescript
|
|
236
|
-
*
|
|
237
|
-
*
|
|
1100
|
+
* export async function POST(request: Request) {
|
|
1101
|
+
* const stream = chat({ adapter: openaiText('gpt-5.5'), messages: [...] });
|
|
1102
|
+
* return toHttpResponse(stream, { durability: { adapter: memoryStream(request) } });
|
|
1103
|
+
* }
|
|
238
1104
|
* ```
|
|
239
1105
|
*/
|
|
240
|
-
export function toHttpResponse(
|
|
1106
|
+
export function toHttpResponse<TOffset extends string = string>(
|
|
241
1107
|
stream: AsyncIterable<StreamChunk>,
|
|
242
|
-
init?: ResponseInit & {
|
|
1108
|
+
init?: ResponseInit & {
|
|
1109
|
+
abortController?: AbortController
|
|
1110
|
+
durability?: { adapter: StreamDurability<TOffset>; batch?: number }
|
|
1111
|
+
/**
|
|
1112
|
+
* Customize logging for durability failure paths (terminal-append and
|
|
1113
|
+
* close). These failures are always logged server-side by default (the
|
|
1114
|
+
* `errors` category is on even without `debug`, via a `ConsoleLogger`);
|
|
1115
|
+
* pass `debug` to route them to a custom `Logger` or raise verbosity. A
|
|
1116
|
+
* joiner replaying the log only ever sees a generic incomplete error, so
|
|
1117
|
+
* server-side logging is where the real cause is recoverable.
|
|
1118
|
+
*/
|
|
1119
|
+
debug?: DebugOption
|
|
1120
|
+
},
|
|
1121
|
+
): Response {
|
|
1122
|
+
const { abortController, durability, debug, headers, ...responseInit } =
|
|
1123
|
+
init ?? {}
|
|
1124
|
+
|
|
1125
|
+
// Default to a streaming NDJSON content type (with no-cache), overridable by
|
|
1126
|
+
// user headers. Without an explicit streaming type some intermediaries buffer
|
|
1127
|
+
// the response, defeating incremental delivery. Mirrors the SSE helper.
|
|
1128
|
+
const mergedHeaders = new Headers({
|
|
1129
|
+
'Content-Type': 'application/x-ndjson',
|
|
1130
|
+
'Cache-Control': 'no-cache',
|
|
1131
|
+
})
|
|
1132
|
+
if (headers) {
|
|
1133
|
+
const userHeaders = new Headers(headers)
|
|
1134
|
+
userHeaders.forEach((value, key) => {
|
|
1135
|
+
mergedHeaders.set(key, value)
|
|
1136
|
+
})
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
let body: ReadableStream<Uint8Array>
|
|
1140
|
+
if (durability) {
|
|
1141
|
+
// See toServerSentEventsResponse: a fresh run drains into the durable log
|
|
1142
|
+
// under its own producer controller, so a response cancel (reload) detaches
|
|
1143
|
+
// and keeps draining in the background instead of killing the run; a resume
|
|
1144
|
+
// response is a reader whose cancel stops the read normally.
|
|
1145
|
+
const isFresh = durability.adapter.resumeFrom() === null
|
|
1146
|
+
const producerAbortController = abortController ?? new AbortController()
|
|
1147
|
+
const deliveryAbortController = isFresh
|
|
1148
|
+
? new AbortController()
|
|
1149
|
+
: producerAbortController
|
|
1150
|
+
const { source, getId } = durableStreamSource(stream, durability.adapter, {
|
|
1151
|
+
abortController: producerAbortController,
|
|
1152
|
+
batch: durability.batch,
|
|
1153
|
+
// Errors-on-by-default logger (see toServerSentEventsResponse).
|
|
1154
|
+
logger: resolveDebugOption(debug),
|
|
1155
|
+
})
|
|
1156
|
+
const { encodeChunk, encodeError } = ndjsonEncoders(getId)
|
|
1157
|
+
body = toEncodedStream(
|
|
1158
|
+
source,
|
|
1159
|
+
deliveryAbortController,
|
|
1160
|
+
encodeChunk,
|
|
1161
|
+
encodeError,
|
|
1162
|
+
isFresh,
|
|
1163
|
+
// See the SSE helper: fresh runs only.
|
|
1164
|
+
isFresh ? () => notifyRunDisconnected(stream) : undefined,
|
|
1165
|
+
)
|
|
1166
|
+
} else {
|
|
1167
|
+
body = toHttpStream(stream, abortController)
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
return new Response(body, {
|
|
1171
|
+
...responseInit,
|
|
1172
|
+
headers: mergedHeaders,
|
|
1173
|
+
})
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* Serve a resumable run from its durability log over NDJSON, without re-running
|
|
1178
|
+
* the model. The NDJSON counterpart of {@link resumeServerSentEventsResponse};
|
|
1179
|
+
* pair it with a `toHttpResponse` producer. Returns a 400 when the request
|
|
1180
|
+
* carries no resume offset (no `Last-Event-ID` header and no `?offset`).
|
|
1181
|
+
*
|
|
1182
|
+
* @example
|
|
1183
|
+
* ```typescript
|
|
1184
|
+
* export async function GET(request: Request) {
|
|
1185
|
+
* return resumeHttpResponse({ adapter: memoryStream(request) });
|
|
1186
|
+
* }
|
|
1187
|
+
* ```
|
|
1188
|
+
*/
|
|
1189
|
+
export function resumeHttpResponse<TOffset extends string = string>(
|
|
1190
|
+
options: ResumeResponseOptions<TOffset>,
|
|
243
1191
|
): Response {
|
|
244
|
-
|
|
245
|
-
|
|
1192
|
+
// See `resumeServerSentEventsResponse`: `driver` must not reach `responseInit`.
|
|
1193
|
+
const { adapter, batch, debug, driver, ...responseInit } = options
|
|
1194
|
+
if (adapter.resumeFrom() === null) {
|
|
1195
|
+
return new Response(NO_RESUME_OFFSET, { status: 400 })
|
|
1196
|
+
}
|
|
1197
|
+
maybeStartRunDriver(driver)
|
|
1198
|
+
return toHttpResponse(emptyDurableSource(), {
|
|
1199
|
+
...responseInit,
|
|
1200
|
+
durability: { adapter, batch },
|
|
1201
|
+
debug,
|
|
246
1202
|
})
|
|
247
1203
|
}
|