@tanstack/ai 0.45.1 → 0.47.1
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/dist/esm/activities/chat/index.d.ts +36 -11
- package/dist/esm/activities/chat/index.js +462 -66
- package/dist/esm/activities/chat/index.js.map +1 -1
- package/dist/esm/activities/chat/messages.d.ts +1 -0
- package/dist/esm/activities/chat/messages.js +12 -7
- package/dist/esm/activities/chat/messages.js.map +1 -1
- package/dist/esm/activities/chat/middleware/builder.d.ts +7 -2
- package/dist/esm/activities/chat/middleware/builder.js.map +1 -1
- package/dist/esm/activities/chat/middleware/compose.d.ts +10 -3
- package/dist/esm/activities/chat/middleware/compose.js +55 -0
- package/dist/esm/activities/chat/middleware/compose.js.map +1 -1
- package/dist/esm/activities/chat/middleware/define.d.ts +6 -3
- package/dist/esm/activities/chat/middleware/define.js.map +1 -1
- package/dist/esm/activities/chat/middleware/generic-interrupts.d.ts +13 -0
- package/dist/esm/activities/chat/middleware/generic-interrupts.js +8 -0
- package/dist/esm/activities/chat/middleware/generic-interrupts.js.map +1 -0
- package/dist/esm/activities/chat/middleware/index.d.ts +4 -1
- package/dist/esm/activities/chat/middleware/types.d.ts +54 -3
- package/dist/esm/activities/chat/middleware/types.js +16 -0
- package/dist/esm/activities/chat/middleware/types.js.map +1 -0
- package/dist/esm/activities/chat/stream/processor.js +18 -5
- package/dist/esm/activities/chat/stream/processor.js.map +1 -1
- package/dist/esm/activities/chat/tools/unique-tool-names.d.ts +20 -0
- package/dist/esm/activities/chat/tools/unique-tool-names.js +57 -0
- package/dist/esm/activities/chat/tools/unique-tool-names.js.map +1 -0
- package/dist/esm/adapter-internals.d.ts +7 -0
- package/dist/esm/adapter-internals.js +5 -1
- package/dist/esm/client.d.ts +4 -0
- package/dist/esm/client.js +3 -1
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/generic-interrupt-continuation.d.ts +45 -0
- package/dist/esm/generic-interrupt-continuation.js +80 -0
- package/dist/esm/generic-interrupt-continuation.js.map +1 -0
- package/dist/esm/index.d.ts +9 -1
- package/dist/esm/index.js +7 -2
- package/dist/esm/interrupt-definition.d.ts +113 -0
- package/dist/esm/interrupt-definition.js +169 -0
- package/dist/esm/interrupt-definition.js.map +1 -0
- package/dist/esm/interrupt-resume.d.ts +3 -0
- package/dist/esm/interrupt-resume.js +77 -16
- package/dist/esm/interrupt-resume.js.map +1 -1
- package/dist/esm/interrupts.d.ts +12 -3
- package/dist/esm/interrupts.js.map +1 -1
- package/dist/esm/middlewares/usage-attributes.d.ts +2 -2
- package/dist/esm/middlewares/usage-attributes.js +9 -2
- package/dist/esm/middlewares/usage-attributes.js.map +1 -1
- package/dist/esm/stream-to-response.d.ts +26 -0
- package/dist/esm/stream-to-response.js +1 -1
- package/dist/esm/stream-to-response.js.map +1 -1
- package/dist/esm/stream-to-websocket.d.ts +123 -0
- package/dist/esm/stream-to-websocket.js +249 -0
- package/dist/esm/stream-to-websocket.js.map +1 -0
- package/dist/esm/types.d.ts +19 -10
- package/dist/esm/utilities/chat-params.js +10 -1
- package/dist/esm/utilities/chat-params.js.map +1 -1
- package/package.json +2 -2
- package/skills/ai-core/media-generation/SKILL.md +13 -9
- package/skills/ai-core/middleware/SKILL.md +53 -44
- package/skills/ai-core/structured-outputs/SKILL.md +59 -55
- package/skills/ai-core/tool-calling/SKILL.md +54 -1
- package/src/activities/chat/index.ts +1076 -194
- package/src/activities/chat/messages.ts +11 -3
- package/src/activities/chat/middleware/builder.ts +29 -4
- package/src/activities/chat/middleware/compose.ts +95 -5
- package/src/activities/chat/middleware/define.ts +13 -3
- package/src/activities/chat/middleware/generic-interrupts.ts +26 -0
- package/src/activities/chat/middleware/index.ts +15 -0
- package/src/activities/chat/middleware/types.ts +127 -2
- package/src/activities/chat/stream/processor.ts +21 -0
- package/src/activities/chat/tools/unique-tool-names.ts +73 -0
- package/src/adapter-internals.ts +24 -0
- package/src/client.ts +20 -0
- package/src/generic-interrupt-continuation.ts +162 -0
- package/src/index.ts +51 -0
- package/src/interrupt-definition.ts +581 -0
- package/src/interrupt-resume.ts +156 -25
- package/src/interrupts.ts +13 -3
- package/src/middlewares/usage-attributes.ts +12 -2
- package/src/stream-to-response.ts +2 -2
- package/src/stream-to-websocket.ts +418 -0
- package/src/types.ts +21 -8
- package/src/utilities/chat-params.ts +16 -3
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { chatParamsFromRequestBody } from './utilities/chat-params'
|
|
2
|
+
import { durableStreamSource, runErrorChunk } from './stream-to-response'
|
|
3
|
+
import { resolveDebugOption } from './logger/resolve'
|
|
4
|
+
import type { StreamDurability } from './stream-durability'
|
|
5
|
+
import type { DebugOption } from './logger/types'
|
|
6
|
+
import type { ModelMessage, StreamChunk, UIMessage } from './types'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The minimal WHATWG WebSocket surface the core needs. Cloudflare
|
|
10
|
+
* `WebSocketPair` server sockets, Deno's upgraded sockets, and `ws` (Node)
|
|
11
|
+
* sockets already satisfy it; Bun's `ServerWebSocket` (handler-object API)
|
|
12
|
+
* gets a ~10-line adapter at the call site.
|
|
13
|
+
*/
|
|
14
|
+
export interface WebSocketLike {
|
|
15
|
+
send: (data: string) => void
|
|
16
|
+
close: (code?: number, reason?: string) => void
|
|
17
|
+
addEventListener: {
|
|
18
|
+
(type: 'message', handler: (ev: { data: unknown }) => void): void
|
|
19
|
+
(type: 'close' | 'error', handler: () => void): void
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One inbound WS text frame, after JSON parse + shape discrimination. */
|
|
24
|
+
export type InboundFrame =
|
|
25
|
+
| { kind: 'run'; input: unknown }
|
|
26
|
+
| { kind: 'abort'; runId: string }
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Encode one server→client frame. Durable frames carry the opaque offset in an
|
|
30
|
+
* `{ id, chunk }` envelope (identical to the NDJSON wire); non-durable frames
|
|
31
|
+
* are the bare chunk. Unambiguous because a bare chunk always has a top-level
|
|
32
|
+
* `type` and the envelope never does.
|
|
33
|
+
*/
|
|
34
|
+
export function encodeWsFrame(
|
|
35
|
+
chunk: StreamChunk,
|
|
36
|
+
id: string | undefined,
|
|
37
|
+
): string {
|
|
38
|
+
return JSON.stringify(id === undefined ? chunk : { id, chunk })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Decode one client→server frame. An `{ type: 'abort', runId }` object is a
|
|
43
|
+
* control frame; anything else is treated as a `RunAgentInput` and validated
|
|
44
|
+
* downstream by `chatParamsFromRequestBody`.
|
|
45
|
+
*/
|
|
46
|
+
export function decodeWsFrame(data: string): InboundFrame {
|
|
47
|
+
const parsed: unknown = JSON.parse(data)
|
|
48
|
+
if (
|
|
49
|
+
typeof parsed === 'object' &&
|
|
50
|
+
parsed !== null &&
|
|
51
|
+
(parsed as { type?: unknown }).type === 'abort' &&
|
|
52
|
+
typeof (parsed as { runId?: unknown }).runId === 'string'
|
|
53
|
+
) {
|
|
54
|
+
return { kind: 'abort', runId: (parsed as { runId: string }).runId }
|
|
55
|
+
}
|
|
56
|
+
return { kind: 'run', input: parsed }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Per-turn context for one inbound `run` frame on a conversation-scoped socket. */
|
|
60
|
+
export interface WsRunContext {
|
|
61
|
+
messages: Array<UIMessage | ModelMessage>
|
|
62
|
+
threadId: string
|
|
63
|
+
runId: string
|
|
64
|
+
forwardedProps?: Record<string, unknown>
|
|
65
|
+
/** Synthetic per-turn request carrying `?runId=` so durability keys correctly. */
|
|
66
|
+
request: Request
|
|
67
|
+
/** Aborts on socket close or an `abort` control frame for this run. */
|
|
68
|
+
signal: AbortSignal
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build the synthetic per-turn request. A conversation-scoped socket multiplexes
|
|
73
|
+
* many runs; each turn's durability adapter must key on the frame's `runId`,
|
|
74
|
+
* which we carry in the URL query (`memoryStream`/`durableStream` already read
|
|
75
|
+
* `?runId` / `?offset` there). Headers are copied from the handshake so
|
|
76
|
+
* auth/cookies survive. A handshake carrying `?offset` is a resume and never
|
|
77
|
+
* reaches a fresh turn (`resumeWebSocketStream` serves it), so the offset is
|
|
78
|
+
* scrubbed here — otherwise a mis-routed resume handshake would make the turn's
|
|
79
|
+
* durability adapter silently take the replay branch instead of running onRun.
|
|
80
|
+
*/
|
|
81
|
+
export function buildTurnRequest(handshake: Request, runId: string): Request {
|
|
82
|
+
const url = new URL(handshake.url)
|
|
83
|
+
url.searchParams.set('runId', runId)
|
|
84
|
+
url.searchParams.delete('offset')
|
|
85
|
+
return new Request(url, { headers: handshake.headers })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface WebSocketStreamInit<TOffset extends string = string> {
|
|
89
|
+
/** Build a fresh chat() stream for each inbound RunAgentInput frame. */
|
|
90
|
+
onRun: (ctx: WsRunContext) => AsyncIterable<StreamChunk>
|
|
91
|
+
/** Per-TURN durability factory, keyed by the frame's runId via ctx.request. */
|
|
92
|
+
durability?: (ctx: WsRunContext) => StreamDurability<TOffset>
|
|
93
|
+
/** Chunks buffered per durability append (default 32). */
|
|
94
|
+
batch?: number
|
|
95
|
+
/** Heartbeat ping interval in ms (default 30_000). */
|
|
96
|
+
heartbeatMs?: number
|
|
97
|
+
/**
|
|
98
|
+
* Close after this many ms without any inbound frame (default 300_000).
|
|
99
|
+
* Never fires while a turn is still streaming, so a long single generation
|
|
100
|
+
* (agentic loop, >5-min turn) is safe.
|
|
101
|
+
*/
|
|
102
|
+
idleTimeoutMs?: number
|
|
103
|
+
debug?: DebugOption
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Run a full-duplex, conversation-scoped chat over an already-accepted server
|
|
108
|
+
* socket. Each inbound RunAgentInput frame starts one chat() turn (via onRun)
|
|
109
|
+
* whose chunks are pumped back as frames; the socket stays open across turns
|
|
110
|
+
* (pending client-tool resubmit, next user message) until the client closes it
|
|
111
|
+
* or the idle timeout fires. An abort control frame aborts only its turn.
|
|
112
|
+
*/
|
|
113
|
+
export function toWebSocketStream<TOffset extends string = string>(
|
|
114
|
+
socket: WebSocketLike,
|
|
115
|
+
request: Request,
|
|
116
|
+
init: WebSocketStreamInit<TOffset>,
|
|
117
|
+
): void {
|
|
118
|
+
const logger = resolveDebugOption(init.debug)
|
|
119
|
+
const activeTurns = new Map<string, AbortController>()
|
|
120
|
+
// Abort frames that raced ahead of their run's registration: `handleInbound`
|
|
121
|
+
// awaits body validation before it registers into `activeTurns`, so an abort
|
|
122
|
+
// arriving inside that window would otherwise be silently discarded.
|
|
123
|
+
const earlyAborts = new Set<string>()
|
|
124
|
+
const heartbeatMs = init.heartbeatMs ?? 30_000
|
|
125
|
+
const idleTimeoutMs = init.idleTimeoutMs ?? 300_000
|
|
126
|
+
let lastActivity = Date.now()
|
|
127
|
+
let closed = false
|
|
128
|
+
|
|
129
|
+
const heartbeat = setInterval(() => {
|
|
130
|
+
try {
|
|
131
|
+
socket.send(JSON.stringify({ type: 'ping' }))
|
|
132
|
+
} catch {
|
|
133
|
+
// Socket is CLOSING/CLOSED between ticks — teardown below clears this
|
|
134
|
+
// interval; swallow so the timer callback doesn't throw uncaught in the
|
|
135
|
+
// meantime.
|
|
136
|
+
}
|
|
137
|
+
}, heartbeatMs)
|
|
138
|
+
const idle = setInterval(
|
|
139
|
+
() => {
|
|
140
|
+
// Never idle-reap while a turn is in flight: a long single onRun
|
|
141
|
+
// iteration (agentic loop / >5-min generation) sends no INBOUND
|
|
142
|
+
// frames, so idle would otherwise fire and kill live work.
|
|
143
|
+
if (activeTurns.size === 0 && Date.now() - lastActivity > idleTimeoutMs) {
|
|
144
|
+
socket.close(1000, 'idle')
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
Math.min(idleTimeoutMs, 30_000),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
function teardown(): void {
|
|
151
|
+
closed = true
|
|
152
|
+
for (const controller of activeTurns.values()) controller.abort()
|
|
153
|
+
activeTurns.clear()
|
|
154
|
+
clearInterval(heartbeat)
|
|
155
|
+
clearInterval(idle)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
socket.addEventListener('close', teardown)
|
|
159
|
+
// Without this, an errored socket whose `close` never follows would leak
|
|
160
|
+
// both intervals and never abort its turns — and on `ws` (an EventEmitter)
|
|
161
|
+
// an `error` event with no listener is thrown as an uncaught exception.
|
|
162
|
+
socket.addEventListener('error', () => {
|
|
163
|
+
logger.errors('WebSocket errored; aborting its turns')
|
|
164
|
+
teardown()
|
|
165
|
+
try {
|
|
166
|
+
socket.close(1011, 'socket error')
|
|
167
|
+
} catch {
|
|
168
|
+
// socket already closing/closed — nothing to do
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
socket.addEventListener('message', (event: { data: unknown }) => {
|
|
173
|
+
if (typeof event.data !== 'string') return
|
|
174
|
+
lastActivity = Date.now()
|
|
175
|
+
|
|
176
|
+
// Inbound frames are client-controlled: a malformed frame (bad JSON, or
|
|
177
|
+
// valid JSON that isn't an AG-UI RunAgentInput/abort shape) must be
|
|
178
|
+
// dropped, not crash the socket or leak an unhandled rejection.
|
|
179
|
+
let frame: InboundFrame
|
|
180
|
+
try {
|
|
181
|
+
frame = decodeWsFrame(event.data)
|
|
182
|
+
} catch (error) {
|
|
183
|
+
logger.errors('Failed to decode inbound WS frame; dropping it', {
|
|
184
|
+
error,
|
|
185
|
+
})
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (frame.kind === 'abort') {
|
|
190
|
+
const turn = activeTurns.get(frame.runId)
|
|
191
|
+
if (turn) turn.abort()
|
|
192
|
+
else earlyAborts.add(frame.runId)
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
void handleInbound(frame.input)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Surface a turn failure to the client as a live `RUN_ERROR` frame. The
|
|
201
|
+
* socket is conversation-scoped and stays open, so without this frame the
|
|
202
|
+
* client would see neither a terminal chunk nor a close — a permanent hang.
|
|
203
|
+
* Mirrors the HTTP transports, which synthesize the live `RUN_ERROR` when
|
|
204
|
+
* the producer rethrows (see `durableStreamSource`'s terminal contract).
|
|
205
|
+
*/
|
|
206
|
+
function sendRunError(error: unknown): void {
|
|
207
|
+
try {
|
|
208
|
+
socket.send(encodeWsFrame(runErrorChunk(error), undefined))
|
|
209
|
+
} catch {
|
|
210
|
+
// Socket is CLOSING/CLOSED — the client sees onclose instead.
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function handleInbound(input: unknown): Promise<void> {
|
|
215
|
+
let params: Awaited<ReturnType<typeof chatParamsFromRequestBody>>
|
|
216
|
+
try {
|
|
217
|
+
params = await chatParamsFromRequestBody(input)
|
|
218
|
+
} catch (error) {
|
|
219
|
+
logger.errors('Invalid inbound WS run frame; dropping it', { error })
|
|
220
|
+
sendRunError(error)
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
// The socket may have closed (or errored) during the await above — the
|
|
224
|
+
// teardown that drains `activeTurns` already ran, so registering now
|
|
225
|
+
// would start a turn nothing can ever abort.
|
|
226
|
+
if (closed) return
|
|
227
|
+
const turnAbort = new AbortController()
|
|
228
|
+
// A second inbound frame with the same runId (client resubmit) must
|
|
229
|
+
// abort the earlier turn. Otherwise the old controller is overwritten
|
|
230
|
+
// and close/abort frames can no longer reach it.
|
|
231
|
+
activeTurns.get(params.runId)?.abort()
|
|
232
|
+
activeTurns.set(params.runId, turnAbort)
|
|
233
|
+
if (earlyAborts.delete(params.runId)) turnAbort.abort()
|
|
234
|
+
const ctx: WsRunContext = {
|
|
235
|
+
messages: params.messages,
|
|
236
|
+
threadId: params.threadId,
|
|
237
|
+
runId: params.runId,
|
|
238
|
+
forwardedProps: params.forwardedProps,
|
|
239
|
+
request: buildTurnRequest(request, params.runId),
|
|
240
|
+
signal: turnAbort.signal,
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
if (init.durability) {
|
|
244
|
+
const adapter = init.durability(ctx)
|
|
245
|
+
const { source, getId } = durableStreamSource(
|
|
246
|
+
init.onRun(ctx),
|
|
247
|
+
adapter,
|
|
248
|
+
{
|
|
249
|
+
abortController: turnAbort,
|
|
250
|
+
...(init.batch === undefined ? {} : { batch: init.batch }),
|
|
251
|
+
logger,
|
|
252
|
+
},
|
|
253
|
+
)
|
|
254
|
+
for await (const chunk of source) {
|
|
255
|
+
socket.send(encodeWsFrame(chunk, getId(chunk)))
|
|
256
|
+
}
|
|
257
|
+
} else {
|
|
258
|
+
for await (const chunk of init.onRun(ctx)) {
|
|
259
|
+
socket.send(encodeWsFrame(chunk, undefined))
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
} catch (error) {
|
|
263
|
+
// An aborted turn (socket close, abort frame, same-runId resubmit) is
|
|
264
|
+
// expected teardown, not a turn failure — nothing to report.
|
|
265
|
+
if (!turnAbort.signal.aborted) {
|
|
266
|
+
logger.errors('WS turn failed', { error })
|
|
267
|
+
sendRunError(error)
|
|
268
|
+
}
|
|
269
|
+
} finally {
|
|
270
|
+
// Only delete if this turn still owns the entry: a duplicate in-flight
|
|
271
|
+
// runId (e.g. a client resubmitting before the first turn finished)
|
|
272
|
+
// would otherwise let the OLDER turn's cleanup delete the NEWER turn's
|
|
273
|
+
// still-active controller (TOCTOU).
|
|
274
|
+
if (activeTurns.get(params.runId) === turnAbort) {
|
|
275
|
+
activeTurns.delete(params.runId)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* A resume is served entirely from the durability log, so there is no
|
|
283
|
+
* producer to iterate. This empty source satisfies `durableStreamSource`'s
|
|
284
|
+
* signature; on a resume it replays from the log and never touches this.
|
|
285
|
+
* Mirrors the private helper of the same name in `stream-to-response.ts`.
|
|
286
|
+
*/
|
|
287
|
+
function emptyDurableSource(): AsyncIterable<StreamChunk> {
|
|
288
|
+
return (async function* () {})()
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Read-only replay of a run's durability log over a socket (mirrors
|
|
293
|
+
* `resumeServerSentEventsResponse`). The adapter captures the offset from the
|
|
294
|
+
* request (`?offset`/`Last-Event-ID`); no model runs. Closes 1008 when there
|
|
295
|
+
* is nothing to resume.
|
|
296
|
+
*/
|
|
297
|
+
export function resumeWebSocketStream<TOffset extends string = string>(
|
|
298
|
+
socket: WebSocketLike,
|
|
299
|
+
options: {
|
|
300
|
+
adapter: StreamDurability<TOffset>
|
|
301
|
+
batch?: number
|
|
302
|
+
debug?: DebugOption
|
|
303
|
+
},
|
|
304
|
+
): void {
|
|
305
|
+
const logger = resolveDebugOption(options.debug)
|
|
306
|
+
if (options.adapter.resumeFrom() === null) {
|
|
307
|
+
socket.close(1008, 'no resume offset')
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
const abortController = new AbortController()
|
|
311
|
+
socket.addEventListener('close', () => abortController.abort())
|
|
312
|
+
// An `error` with no listener is an uncaught exception on `ws`; abort the
|
|
313
|
+
// replay so the pump below stops instead of writing to a dead socket.
|
|
314
|
+
socket.addEventListener('error', () => abortController.abort())
|
|
315
|
+
const { source, getId } = durableStreamSource(
|
|
316
|
+
emptyDurableSource(),
|
|
317
|
+
options.adapter,
|
|
318
|
+
{
|
|
319
|
+
abortController,
|
|
320
|
+
...(options.batch === undefined ? {} : { batch: options.batch }),
|
|
321
|
+
logger,
|
|
322
|
+
},
|
|
323
|
+
)
|
|
324
|
+
void (async () => {
|
|
325
|
+
for await (const chunk of source) {
|
|
326
|
+
socket.send(encodeWsFrame(chunk, getId(chunk)))
|
|
327
|
+
}
|
|
328
|
+
// Source exhausted = the durability log is complete/terminal; nothing more
|
|
329
|
+
// will arrive on this read-only socket. Close so the client's reconnect
|
|
330
|
+
// loop sees onclose and terminates (bounded) instead of awaiting a chunk
|
|
331
|
+
// that never comes. Safe across durability models: a live decoupled
|
|
332
|
+
// producer (e.g. durableStream) keeps `read` parked until the terminal,
|
|
333
|
+
// so the source doesn't exhaust until the run truly ends; a completed
|
|
334
|
+
// in-process log closes immediately.
|
|
335
|
+
try {
|
|
336
|
+
socket.close(1000)
|
|
337
|
+
} catch {
|
|
338
|
+
// socket already closing/closed — nothing to do
|
|
339
|
+
}
|
|
340
|
+
})().catch((error: unknown) => {
|
|
341
|
+
logger.errors('resume websocket replay failed', { error })
|
|
342
|
+
try {
|
|
343
|
+
socket.close(1011, 'resume failed')
|
|
344
|
+
} catch {
|
|
345
|
+
// socket already closing/closed — nothing to do
|
|
346
|
+
}
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
interface WebSocketPairCtor {
|
|
351
|
+
new (): { 0: unknown; 1: WebSocketLike & { accept?: () => void } }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function upgradeOrThrow(helper: string): {
|
|
355
|
+
client: unknown
|
|
356
|
+
server: WebSocketLike
|
|
357
|
+
} {
|
|
358
|
+
const Pair = (globalThis as { WebSocketPair?: WebSocketPairCtor })
|
|
359
|
+
.WebSocketPair
|
|
360
|
+
if (!Pair) {
|
|
361
|
+
throw new Error(
|
|
362
|
+
`${helper} requires a runtime with WebSocketPair (Cloudflare Workers/Durable Objects). ` +
|
|
363
|
+
`On other runtimes upgrade the socket yourself and call ${helper.replace('Response', 'Stream')}.`,
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
const pair = new Pair()
|
|
367
|
+
const server = pair[1]
|
|
368
|
+
server.accept?.()
|
|
369
|
+
return { client: pair[0], server }
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function upgradeResponse(client: unknown): Response {
|
|
373
|
+
return new Response(null, {
|
|
374
|
+
status: 101,
|
|
375
|
+
// Cloudflare-specific field; typed loosely to avoid a DOM lib dependency.
|
|
376
|
+
webSocket: client,
|
|
377
|
+
} as ResponseInit & { webSocket: unknown })
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Cloudflare wrapper (Workers/Durable Objects): creates a `WebSocketPair`,
|
|
382
|
+
* accepts the server socket, delegates to {@link toWebSocketStream}, and
|
|
383
|
+
* returns the 101 upgrade `Response` carrying the client socket. Throws when
|
|
384
|
+
* the runtime has no `WebSocketPair` (Node, Deno, Bun) — upgrade the socket
|
|
385
|
+
* yourself and call {@link toWebSocketStream} directly there.
|
|
386
|
+
*/
|
|
387
|
+
export function toWebSocketResponse<TOffset extends string = string>(
|
|
388
|
+
request: Request,
|
|
389
|
+
init: WebSocketStreamInit<TOffset>,
|
|
390
|
+
): Response {
|
|
391
|
+
const { client, server } = upgradeOrThrow('toWebSocketResponse')
|
|
392
|
+
toWebSocketStream(server, request, init)
|
|
393
|
+
return upgradeResponse(client)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Cloudflare wrapper (Workers/Durable Objects): creates a `WebSocketPair`,
|
|
398
|
+
* accepts the server socket, delegates to {@link resumeWebSocketStream}, and
|
|
399
|
+
* returns the 101 upgrade `Response` carrying the client socket. Throws when
|
|
400
|
+
* the runtime has no `WebSocketPair` (Node, Deno, Bun) — upgrade the socket
|
|
401
|
+
* yourself and call {@link resumeWebSocketStream} directly there.
|
|
402
|
+
*
|
|
403
|
+
* @example
|
|
404
|
+
* ```ts
|
|
405
|
+
* resumeWebSocketResponse({ adapter: memoryStream(request) })
|
|
406
|
+
* ```
|
|
407
|
+
*/
|
|
408
|
+
export function resumeWebSocketResponse<
|
|
409
|
+
TOffset extends string = string,
|
|
410
|
+
>(options: {
|
|
411
|
+
adapter: StreamDurability<TOffset>
|
|
412
|
+
batch?: number
|
|
413
|
+
debug?: DebugOption
|
|
414
|
+
}): Response {
|
|
415
|
+
const { client, server } = upgradeOrThrow('resumeWebSocketResponse')
|
|
416
|
+
resumeWebSocketStream(server, options)
|
|
417
|
+
return upgradeResponse(client)
|
|
418
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -11,6 +11,8 @@ import type { ProviderTool } from './tools/provider-tool'
|
|
|
11
11
|
// package (which `@tanstack/ai` already depends on) so there is a single source
|
|
12
12
|
// of truth without a dependency cycle. They are re-exported below.
|
|
13
13
|
import type {
|
|
14
|
+
BilledUsage,
|
|
15
|
+
BillingUnit,
|
|
14
16
|
CompletionTokensDetails,
|
|
15
17
|
PromptTokensDetails,
|
|
16
18
|
ProviderUsageDetails,
|
|
@@ -367,6 +369,12 @@ export interface ModelMessage<
|
|
|
367
369
|
toolCalls?: Array<ToolCall>
|
|
368
370
|
toolCallId?: string
|
|
369
371
|
thinking?: Array<{ content: string; signature?: string }>
|
|
372
|
+
/**
|
|
373
|
+
* Completed structured output represented by this assistant message.
|
|
374
|
+
* `content` remains the provider-facing JSON text; this field preserves the
|
|
375
|
+
* typed UI part across persistence and message conversion.
|
|
376
|
+
*/
|
|
377
|
+
structuredOutput?: StructuredOutputPart
|
|
370
378
|
/**
|
|
371
379
|
* Optional stable message id. Providers ignore it; it exists so a persisted
|
|
372
380
|
* transcript can retain the streaming `messageId` and survive the
|
|
@@ -1026,8 +1034,7 @@ export interface TextOptions<
|
|
|
1026
1034
|
|
|
1027
1035
|
/**
|
|
1028
1036
|
* AG-UI interrupt resume responses supplied by the client on a follow-up run.
|
|
1029
|
-
*
|
|
1030
|
-
* upstream-native interrupts.
|
|
1037
|
+
* A first-party generic item carries the original request in `metadata`.
|
|
1031
1038
|
*/
|
|
1032
1039
|
resume?: Array<RunAgentResumeItem>
|
|
1033
1040
|
|
|
@@ -1106,6 +1113,8 @@ export interface RunStartedEvent extends AGUIRunStartedEvent {
|
|
|
1106
1113
|
// Re-export the canonical usage types (defined in `@tanstack/ai-event-client`)
|
|
1107
1114
|
// so `@tanstack/ai` consumers keep importing them from here unchanged.
|
|
1108
1115
|
export type {
|
|
1116
|
+
BilledUsage,
|
|
1117
|
+
BillingUnit,
|
|
1109
1118
|
CompletionTokensDetails,
|
|
1110
1119
|
PromptTokensDetails,
|
|
1111
1120
|
ProviderUsageDetails,
|
|
@@ -1124,7 +1133,10 @@ export type Interrupt = AGUIInterrupt
|
|
|
1124
1133
|
|
|
1125
1134
|
export type RunFinishedOutcome = AGUIRunFinishedOutcome
|
|
1126
1135
|
|
|
1127
|
-
export type RunAgentResumeItem = AGUIResumeEntry
|
|
1136
|
+
export type RunAgentResumeItem = AGUIResumeEntry & {
|
|
1137
|
+
/** AG-UI resume metadata. First-party generic requests ride here. */
|
|
1138
|
+
metadata?: Record<string, unknown>
|
|
1139
|
+
}
|
|
1128
1140
|
|
|
1129
1141
|
/**
|
|
1130
1142
|
* Emitted when a run completes successfully.
|
|
@@ -2078,9 +2090,10 @@ export interface RerankResult<TDocument = string> {
|
|
|
2078
2090
|
rerankedDocuments: Array<TDocument>
|
|
2079
2091
|
/**
|
|
2080
2092
|
* Usage for the request. Rerank typically bills in provider-defined "search
|
|
2081
|
-
* units" (`usage.
|
|
2082
|
-
* OpenRouter) may also report `totalTokens` and `cost
|
|
2083
|
-
* search units and leaves the token counts at 0.
|
|
2093
|
+
* units" (`usage.billed = { quantity, unit: 'units' }`) rather than tokens.
|
|
2094
|
+
* Some providers (e.g. OpenRouter) may also report `totalTokens` and `cost`.
|
|
2095
|
+
* Cohere reports only search units and leaves the token counts at 0.
|
|
2096
|
+
* The deprecated `unitsBilled` field is still populated for compatibility.
|
|
2084
2097
|
*/
|
|
2085
2098
|
usage: TokenUsage
|
|
2086
2099
|
}
|
|
@@ -2463,8 +2476,8 @@ export interface VideoUrlResult {
|
|
|
2463
2476
|
expiresAt?: Date
|
|
2464
2477
|
/**
|
|
2465
2478
|
* Usage information for the completed generation, when the adapter can report
|
|
2466
|
-
* it. For usage-based providers (e.g. fal) this carries `
|
|
2467
|
-
*
|
|
2479
|
+
* it. For usage-based providers (e.g. fal) this carries `billed` — the real
|
|
2480
|
+
* billed quantity paired with its unit — so consumers can compute exact cost.
|
|
2468
2481
|
*/
|
|
2469
2482
|
usage?: TokenUsage
|
|
2470
2483
|
/** Persisted artifact references for generated assets, when available */
|
|
@@ -2,7 +2,6 @@ import { AGUIError } from '@ag-ui/core'
|
|
|
2
2
|
import type {
|
|
3
3
|
Context as AGUIContext,
|
|
4
4
|
Message as AGUIMessage,
|
|
5
|
-
ResumeEntry as AGUIResumeEntry,
|
|
6
5
|
Role as AGUIRole,
|
|
7
6
|
} from '@ag-ui/core'
|
|
8
7
|
import type {
|
|
@@ -22,6 +21,7 @@ const KNOWN_PART_TYPES = new Set([
|
|
|
22
21
|
'tool-call',
|
|
23
22
|
'tool-result',
|
|
24
23
|
'thinking',
|
|
24
|
+
'structured-output',
|
|
25
25
|
])
|
|
26
26
|
|
|
27
27
|
function isValidParts(value: unknown): value is Array<{ type: string }> {
|
|
@@ -30,6 +30,10 @@ function isValidParts(value: unknown): value is Array<{ type: string }> {
|
|
|
30
30
|
if (!p || typeof p !== 'object') return false
|
|
31
31
|
const type = (p as { type?: unknown }).type
|
|
32
32
|
if (typeof type !== 'string' || !KNOWN_PART_TYPES.has(type)) return false
|
|
33
|
+
if (type === 'structured-output') {
|
|
34
|
+
const raw = (p as { raw?: unknown }).raw
|
|
35
|
+
if (raw !== undefined && typeof raw !== 'string') return false
|
|
36
|
+
}
|
|
33
37
|
}
|
|
34
38
|
return true
|
|
35
39
|
}
|
|
@@ -173,20 +177,29 @@ function validateContext(value: unknown, index: number): AGUIContext {
|
|
|
173
177
|
}
|
|
174
178
|
}
|
|
175
179
|
|
|
176
|
-
function validateResumeEntry(
|
|
180
|
+
function validateResumeEntry(
|
|
181
|
+
value: unknown,
|
|
182
|
+
index: number,
|
|
183
|
+
): RunAgentResumeItem {
|
|
177
184
|
const at = `resume[${index}]`
|
|
178
185
|
if (!isRecord(value)) invalidBody(`${at} must be an object`)
|
|
179
186
|
const status = value.status
|
|
180
187
|
if (status !== 'resolved' && status !== 'cancelled') {
|
|
181
188
|
invalidBody(`${at}.status must be "resolved" or "cancelled"`)
|
|
182
189
|
}
|
|
183
|
-
const entry:
|
|
190
|
+
const entry: RunAgentResumeItem = {
|
|
184
191
|
interruptId: requireString(value.interruptId, `${at}.interruptId`),
|
|
185
192
|
status,
|
|
186
193
|
}
|
|
187
194
|
// Omit the key entirely when absent, matching the optional-field shape the
|
|
188
195
|
// schema produced.
|
|
189
196
|
if (value.payload !== undefined) entry.payload = value.payload
|
|
197
|
+
if (value.metadata !== undefined) {
|
|
198
|
+
if (!isRecord(value.metadata)) {
|
|
199
|
+
invalidBody(`${at}.metadata must be an object`)
|
|
200
|
+
}
|
|
201
|
+
entry.metadata = value.metadata
|
|
202
|
+
}
|
|
190
203
|
return entry
|
|
191
204
|
}
|
|
192
205
|
|