@tanstack/ai-client 0.22.1 → 0.23.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/README.md +15 -1
- package/dist/esm/audio-recorder.js +190 -213
- package/dist/esm/audio-recorder.js.map +1 -1
- package/dist/esm/chat-client.d.ts +172 -3
- package/dist/esm/chat-client.js +1656 -1386
- package/dist/esm/chat-client.js.map +1 -1
- package/dist/esm/cleared-stream-tracker.d.ts +23 -0
- package/dist/esm/cleared-stream-tracker.js +97 -0
- package/dist/esm/cleared-stream-tracker.js.map +1 -0
- package/dist/esm/client-persistor.d.ts +25 -12
- package/dist/esm/client-persistor.js +260 -235
- package/dist/esm/client-persistor.js.map +1 -1
- package/dist/esm/connection-adapters.d.ts +231 -10
- package/dist/esm/connection-adapters.js +989 -574
- package/dist/esm/connection-adapters.js.map +1 -1
- package/dist/esm/devtools-noop.d.ts +1 -0
- package/dist/esm/devtools-noop.js +79 -139
- package/dist/esm/devtools-noop.js.map +1 -1
- package/dist/esm/devtools.d.ts +31 -1
- package/dist/esm/devtools.js +977 -1127
- package/dist/esm/devtools.js.map +1 -1
- package/dist/esm/events.js +224 -226
- package/dist/esm/events.js.map +1 -1
- package/dist/esm/generation-client.d.ts +145 -2
- package/dist/esm/generation-client.js +659 -321
- package/dist/esm/generation-client.js.map +1 -1
- package/dist/esm/generation-reconstruct.d.ts +21 -0
- package/dist/esm/generation-reconstruct.js +85 -0
- package/dist/esm/generation-reconstruct.js.map +1 -0
- package/dist/esm/generation-types.d.ts +289 -3
- package/dist/esm/generation-types.js +356 -13
- package/dist/esm/generation-types.js.map +1 -1
- package/dist/esm/index.d.ts +9 -4
- package/dist/esm/index.js +7 -39
- package/dist/esm/interrupt-manager.d.ts +77 -0
- package/dist/esm/interrupt-manager.js +787 -0
- package/dist/esm/interrupt-manager.js.map +1 -0
- package/dist/esm/mcp-app-bridge.js +56 -64
- package/dist/esm/mcp-app-bridge.js.map +1 -1
- package/dist/esm/realtime-client.js +366 -440
- package/dist/esm/realtime-client.js.map +1 -1
- package/dist/esm/response-stream.js +19 -26
- package/dist/esm/response-stream.js.map +1 -1
- package/dist/esm/sse-parser.js +44 -47
- package/dist/esm/sse-parser.js.map +1 -1
- package/dist/esm/sse-utils.js +8 -9
- package/dist/esm/sse-utils.js.map +1 -1
- package/dist/esm/storage-adapters.d.ts +62 -0
- package/dist/esm/storage-adapters.js +174 -0
- package/dist/esm/storage-adapters.js.map +1 -0
- package/dist/esm/types.d.ts +212 -10
- package/dist/esm/types.js +38 -7
- package/dist/esm/types.js.map +1 -1
- package/dist/esm/video-generation-client.d.ts +113 -2
- package/dist/esm/video-generation-client.js +665 -379
- package/dist/esm/video-generation-client.js.map +1 -1
- package/package.json +7 -7
- package/src/chat-client.ts +1079 -61
- package/src/cleared-stream-tracker.ts +151 -0
- package/src/client-persistor.ts +102 -33
- package/src/connection-adapters.ts +1185 -142
- package/src/devtools-noop.ts +4 -3
- package/src/devtools.ts +121 -3
- package/src/generation-client.ts +563 -13
- package/src/generation-reconstruct.ts +121 -0
- package/src/generation-types.ts +727 -3
- package/src/index.ts +56 -1
- package/src/interrupt-manager.ts +1440 -0
- package/src/storage-adapters.ts +242 -0
- package/src/types.ts +301 -9
- package/src/video-generation-client.ts +479 -13
- package/dist/esm/index.js.map +0 -1
|
@@ -0,0 +1,1440 @@
|
|
|
1
|
+
import {
|
|
2
|
+
INTERRUPT_BINDING_METADATA_KEY,
|
|
3
|
+
INTERRUPT_BINDING_VERSION,
|
|
4
|
+
canonicalInterruptJson,
|
|
5
|
+
canonicalizeInterruptResolutions,
|
|
6
|
+
cloneAndDeepFreezeJson,
|
|
7
|
+
digestInterruptJson,
|
|
8
|
+
hashSchemaInput,
|
|
9
|
+
isStandardSchema,
|
|
10
|
+
normalizeApprovalSchema,
|
|
11
|
+
} from '@tanstack/ai/client'
|
|
12
|
+
import type {
|
|
13
|
+
AnyClientTool,
|
|
14
|
+
BatchInterruptError,
|
|
15
|
+
Interrupt,
|
|
16
|
+
InterruptBinding,
|
|
17
|
+
InterruptSubmissionError,
|
|
18
|
+
ItemInterruptError,
|
|
19
|
+
RunAgentResumeItem,
|
|
20
|
+
} from '@tanstack/ai/client'
|
|
21
|
+
import type {
|
|
22
|
+
BoundInterruptBase,
|
|
23
|
+
BoundInterrupts,
|
|
24
|
+
ChatInterrupt,
|
|
25
|
+
ChatInterruptState,
|
|
26
|
+
GenericAGUIInterrupt,
|
|
27
|
+
InterruptItemStatus,
|
|
28
|
+
UnboundInterrupt,
|
|
29
|
+
} from './types'
|
|
30
|
+
|
|
31
|
+
export interface InterruptManagerHydration {
|
|
32
|
+
threadId: string
|
|
33
|
+
interruptedRunId: string
|
|
34
|
+
generation: number
|
|
35
|
+
interrupts: ReadonlyArray<Interrupt>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface InterruptManagerSubmission {
|
|
39
|
+
threadId: string
|
|
40
|
+
interruptedRunId: string
|
|
41
|
+
generation: number
|
|
42
|
+
resolutions: ReadonlyArray<RunAgentResumeItem>
|
|
43
|
+
canonicalResolutions: string
|
|
44
|
+
fingerprint: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface InterruptManagerOptions<
|
|
48
|
+
TTools extends ReadonlyArray<AnyClientTool>,
|
|
49
|
+
> {
|
|
50
|
+
tools?: TTools
|
|
51
|
+
submit: (submission: InterruptManagerSubmission) => Promise<void>
|
|
52
|
+
onChange?: () => void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type UnknownObject = { [key: string]: unknown }
|
|
56
|
+
|
|
57
|
+
type RuntimeKind =
|
|
58
|
+
| 'generic'
|
|
59
|
+
| 'tool-approval'
|
|
60
|
+
| 'client-tool-execution'
|
|
61
|
+
/** Carries no binding we understand — not ours to resume. */
|
|
62
|
+
| 'unbound'
|
|
63
|
+
|
|
64
|
+
interface RuntimeInterrupt {
|
|
65
|
+
descriptor: Interrupt
|
|
66
|
+
/** `undefined` only for `unbound` items. */
|
|
67
|
+
binding: InterruptBinding | undefined
|
|
68
|
+
kind: RuntimeKind
|
|
69
|
+
status: InterruptItemStatus
|
|
70
|
+
canResolve: boolean
|
|
71
|
+
error?: ItemInterruptError
|
|
72
|
+
resolution?: RunAgentResumeItem
|
|
73
|
+
tool?: AnyClientTool
|
|
74
|
+
validationGeneration: number
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface ValidationFailure {
|
|
78
|
+
code: ItemInterruptError['code']
|
|
79
|
+
message: string
|
|
80
|
+
path?: ReadonlyArray<string | number>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type ValidationResult = { valid: true; payload: unknown } | ValidationFailure
|
|
84
|
+
|
|
85
|
+
interface TransactionToken {
|
|
86
|
+
active: boolean
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface RuntimeInterruptCheckpoint {
|
|
90
|
+
status: InterruptItemStatus
|
|
91
|
+
resolution?: RunAgentResumeItem
|
|
92
|
+
error?: ItemInterruptError
|
|
93
|
+
validationGeneration: number
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const itemErrorCodes = new Set<ItemInterruptError['code']>([
|
|
97
|
+
'invalid-payload',
|
|
98
|
+
'invalid-edited-args',
|
|
99
|
+
'invalid-tool-output',
|
|
100
|
+
'invalid-response-schema',
|
|
101
|
+
'unknown-interrupt',
|
|
102
|
+
'expired',
|
|
103
|
+
'stale',
|
|
104
|
+
'conflict',
|
|
105
|
+
'legacy-unsupported',
|
|
106
|
+
])
|
|
107
|
+
|
|
108
|
+
const batchErrorCodes = new Set<BatchInterruptError['code']>([
|
|
109
|
+
'incomplete-batch',
|
|
110
|
+
'item-validation-failed',
|
|
111
|
+
'unsupported-bulk-operation',
|
|
112
|
+
'async-resolver',
|
|
113
|
+
'inactive-transaction',
|
|
114
|
+
'mixed-provenance',
|
|
115
|
+
'transport',
|
|
116
|
+
'server',
|
|
117
|
+
'protocol',
|
|
118
|
+
'invalid-response-schema',
|
|
119
|
+
'expired',
|
|
120
|
+
'stale',
|
|
121
|
+
'conflict',
|
|
122
|
+
'legacy-submit-failed',
|
|
123
|
+
])
|
|
124
|
+
|
|
125
|
+
function isUnknownObject(value: unknown): value is UnknownObject {
|
|
126
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isLegacyApprovalMetadata(value: unknown): boolean {
|
|
130
|
+
return (
|
|
131
|
+
isUnknownObject(value) &&
|
|
132
|
+
value['kind'] === 'approval' &&
|
|
133
|
+
typeof value['toolName'] === 'string' &&
|
|
134
|
+
'input' in value
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isLegacyClientToolMetadata(value: unknown): boolean {
|
|
139
|
+
return (
|
|
140
|
+
isUnknownObject(value) &&
|
|
141
|
+
value['kind'] === 'client_tool' &&
|
|
142
|
+
typeof value['toolName'] === 'string' &&
|
|
143
|
+
'input' in value
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Does this descriptor carry the pre-binding TanStack metadata marker?
|
|
149
|
+
*
|
|
150
|
+
* Descriptors emitted before the resume binding existed are still ours to
|
|
151
|
+
* resume, so they must not be mistaken for another producer's interrupt.
|
|
152
|
+
*/
|
|
153
|
+
function isLegacyInterruptMetadata(interrupt: Interrupt): boolean {
|
|
154
|
+
return (
|
|
155
|
+
isLegacyApprovalMetadata(interrupt.metadata) ||
|
|
156
|
+
isLegacyClientToolMetadata(interrupt.metadata)
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isBindingBase(value: UnknownObject): boolean {
|
|
161
|
+
return (
|
|
162
|
+
// A binding stamped with a version we don't know is another producer's.
|
|
163
|
+
// Reject it whole; never read our fields out of it. Missing `v` is read as
|
|
164
|
+
// the current version so pre-versioning bindings still resume.
|
|
165
|
+
(value['v'] === undefined || value['v'] === INTERRUPT_BINDING_VERSION) &&
|
|
166
|
+
typeof value['kind'] === 'string' &&
|
|
167
|
+
typeof value['interruptId'] === 'string' &&
|
|
168
|
+
typeof value['interruptedRunId'] === 'string' &&
|
|
169
|
+
typeof value['generation'] === 'number' &&
|
|
170
|
+
Number.isInteger(value['generation']) &&
|
|
171
|
+
value['generation'] >= 0 &&
|
|
172
|
+
typeof value['responseSchemaHash'] === 'string' &&
|
|
173
|
+
(value['expiresAt'] === undefined ||
|
|
174
|
+
(typeof value['expiresAt'] === 'string' &&
|
|
175
|
+
Number.isFinite(Date.parse(value['expiresAt']))))
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function readBinding(value: unknown): InterruptBinding | undefined {
|
|
180
|
+
if (!isUnknownObject(value) || !isBindingBase(value)) return undefined
|
|
181
|
+
const expiresAt =
|
|
182
|
+
typeof value['expiresAt'] === 'string' ? value['expiresAt'] : undefined
|
|
183
|
+
if (value['kind'] === 'generic') {
|
|
184
|
+
return {
|
|
185
|
+
v: INTERRUPT_BINDING_VERSION,
|
|
186
|
+
kind: 'generic',
|
|
187
|
+
interruptId: String(value['interruptId']),
|
|
188
|
+
interruptedRunId: String(value['interruptedRunId']),
|
|
189
|
+
generation: Number(value['generation']),
|
|
190
|
+
responseSchemaHash: String(value['responseSchemaHash']),
|
|
191
|
+
...(expiresAt !== undefined ? { expiresAt } : {}),
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (
|
|
195
|
+
value['kind'] === 'client-tool-execution' &&
|
|
196
|
+
typeof value['toolName'] === 'string' &&
|
|
197
|
+
typeof value['toolCallId'] === 'string' &&
|
|
198
|
+
typeof value['outputSchemaHash'] === 'string'
|
|
199
|
+
) {
|
|
200
|
+
return {
|
|
201
|
+
v: INTERRUPT_BINDING_VERSION,
|
|
202
|
+
kind: 'client-tool-execution',
|
|
203
|
+
interruptId: String(value['interruptId']),
|
|
204
|
+
interruptedRunId: String(value['interruptedRunId']),
|
|
205
|
+
generation: Number(value['generation']),
|
|
206
|
+
toolName: value['toolName'],
|
|
207
|
+
toolCallId: value['toolCallId'],
|
|
208
|
+
outputSchemaHash: value['outputSchemaHash'],
|
|
209
|
+
responseSchemaHash: String(value['responseSchemaHash']),
|
|
210
|
+
...(expiresAt !== undefined ? { expiresAt } : {}),
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (
|
|
214
|
+
value['kind'] === 'tool-approval' &&
|
|
215
|
+
typeof value['toolName'] === 'string' &&
|
|
216
|
+
typeof value['toolCallId'] === 'string' &&
|
|
217
|
+
typeof value['inputSchemaHash'] === 'string' &&
|
|
218
|
+
typeof value['approvalSchemaHash'] === 'string' &&
|
|
219
|
+
'originalArgs' in value
|
|
220
|
+
) {
|
|
221
|
+
return {
|
|
222
|
+
v: INTERRUPT_BINDING_VERSION,
|
|
223
|
+
kind: 'tool-approval',
|
|
224
|
+
interruptId: String(value['interruptId']),
|
|
225
|
+
interruptedRunId: String(value['interruptedRunId']),
|
|
226
|
+
generation: Number(value['generation']),
|
|
227
|
+
toolName: value['toolName'],
|
|
228
|
+
toolCallId: value['toolCallId'],
|
|
229
|
+
originalArgs: value['originalArgs'],
|
|
230
|
+
inputSchemaHash: value['inputSchemaHash'],
|
|
231
|
+
approvalSchemaHash: value['approvalSchemaHash'],
|
|
232
|
+
responseSchemaHash: String(value['responseSchemaHash']),
|
|
233
|
+
...(expiresAt !== undefined ? { expiresAt } : {}),
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return undefined
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function getDescriptorBinding(
|
|
240
|
+
interrupt: Interrupt,
|
|
241
|
+
): InterruptBinding | undefined {
|
|
242
|
+
const candidate: unknown =
|
|
243
|
+
interrupt.metadata?.[INTERRUPT_BINDING_METADATA_KEY]
|
|
244
|
+
return readBinding(candidate)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Only used to route *legacy* (pre-binding) descriptors, which have no binding
|
|
249
|
+
* to classify off. Current descriptors are classified by their binding alone.
|
|
250
|
+
*/
|
|
251
|
+
function isClientToolExecutionReason(reason: string): boolean {
|
|
252
|
+
return (
|
|
253
|
+
reason === 'tanstack:client_tool_execution' ||
|
|
254
|
+
reason === 'client_tool_input'
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function responseSchemaHash(interrupt: Interrupt): string | undefined {
|
|
259
|
+
if (interrupt.responseSchema === undefined) return undefined
|
|
260
|
+
try {
|
|
261
|
+
return digestInterruptJson(canonicalInterruptJson(interrupt.responseSchema))
|
|
262
|
+
} catch {
|
|
263
|
+
return undefined
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
|
268
|
+
return (
|
|
269
|
+
value !== null &&
|
|
270
|
+
(typeof value === 'object' || typeof value === 'function') &&
|
|
271
|
+
'then' in value &&
|
|
272
|
+
typeof value.then === 'function'
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function validateWithSchema(
|
|
277
|
+
schema: unknown,
|
|
278
|
+
value: unknown,
|
|
279
|
+
code: ItemInterruptError['code'],
|
|
280
|
+
): ValidationResult | Promise<ValidationResult> {
|
|
281
|
+
if (schema === undefined) return { valid: true, payload: value }
|
|
282
|
+
if (isStandardSchema(schema)) {
|
|
283
|
+
const result = schema['~standard'].validate(value)
|
|
284
|
+
const normalize = (
|
|
285
|
+
validation: Awaited<typeof result>,
|
|
286
|
+
): ValidationResult => {
|
|
287
|
+
if (!validation.issues) {
|
|
288
|
+
return { valid: true, payload: validation.value }
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
code,
|
|
292
|
+
message: validation.issues[0]?.message ?? 'Schema validation failed.',
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return isPromiseLike(result)
|
|
296
|
+
? Promise.resolve(result).then(normalize)
|
|
297
|
+
: normalize(result)
|
|
298
|
+
}
|
|
299
|
+
// A non-Standard-Schema value (a raw JSON Schema arriving over the wire) is
|
|
300
|
+
// not validated by the library. The application transforms the schema and
|
|
301
|
+
// validates the value itself before resolving; whatever it passes flows
|
|
302
|
+
// through as-is.
|
|
303
|
+
return { valid: true, payload: value }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function isItemErrorCode(value: string): value is ItemInterruptError['code'] {
|
|
307
|
+
for (const code of itemErrorCodes) if (code === value) return true
|
|
308
|
+
return false
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function isBatchErrorCode(value: string): value is BatchInterruptError['code'] {
|
|
312
|
+
for (const code of batchErrorCodes) if (code === value) return true
|
|
313
|
+
return false
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function isSubmissionError(value: unknown): value is InterruptSubmissionError {
|
|
317
|
+
if (!isUnknownObject(value)) return false
|
|
318
|
+
const scope = value['scope']
|
|
319
|
+
const code = value['code']
|
|
320
|
+
const base =
|
|
321
|
+
typeof code === 'string' &&
|
|
322
|
+
typeof value['message'] === 'string' &&
|
|
323
|
+
typeof value['retryable'] === 'boolean' &&
|
|
324
|
+
typeof value['threadId'] === 'string' &&
|
|
325
|
+
typeof value['interruptedRunId'] === 'string' &&
|
|
326
|
+
typeof value['generation'] === 'number'
|
|
327
|
+
if (!base) return false
|
|
328
|
+
if (scope === 'item') {
|
|
329
|
+
return (
|
|
330
|
+
isItemErrorCode(code) &&
|
|
331
|
+
typeof value['interruptId'] === 'string' &&
|
|
332
|
+
(value['source'] === 'client' || value['source'] === 'server')
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
return (
|
|
336
|
+
scope === 'batch' &&
|
|
337
|
+
isBatchErrorCode(code) &&
|
|
338
|
+
Array.isArray(value['interruptIds']) &&
|
|
339
|
+
value['interruptIds'].every((id) => typeof id === 'string') &&
|
|
340
|
+
(value['source'] === 'client' ||
|
|
341
|
+
value['source'] === 'server' ||
|
|
342
|
+
value['source'] === 'transport')
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function readSubmissionErrors(
|
|
347
|
+
error: unknown,
|
|
348
|
+
): ReadonlyArray<InterruptSubmissionError> {
|
|
349
|
+
if (isSubmissionError(error)) return [error]
|
|
350
|
+
if (!isUnknownObject(error) || !Array.isArray(error['errors'])) return []
|
|
351
|
+
return error['errors'].every(isSubmissionError) ? error['errors'] : []
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function haveSameInterruptIds(
|
|
355
|
+
left: ReadonlyArray<string>,
|
|
356
|
+
right: ReadonlyArray<string>,
|
|
357
|
+
): boolean {
|
|
358
|
+
if (left.length !== right.length) return false
|
|
359
|
+
const sortedLeft = [...left].sort()
|
|
360
|
+
const sortedRight = [...right].sort()
|
|
361
|
+
return sortedLeft.every((id, index) => id === sortedRight[index])
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function haveSameBatchCorrelation(
|
|
365
|
+
left: BatchInterruptError,
|
|
366
|
+
right: BatchInterruptError,
|
|
367
|
+
): boolean {
|
|
368
|
+
return (
|
|
369
|
+
left.threadId === right.threadId &&
|
|
370
|
+
left.interruptedRunId === right.interruptedRunId &&
|
|
371
|
+
left.generation === right.generation &&
|
|
372
|
+
haveSameInterruptIds(left.interruptIds, right.interruptIds)
|
|
373
|
+
)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function mergeSubmissionBatchErrors(
|
|
377
|
+
current: ReadonlyArray<BatchInterruptError>,
|
|
378
|
+
previousSubmission: ReadonlyArray<BatchInterruptError>,
|
|
379
|
+
incoming: ReadonlyArray<BatchInterruptError>,
|
|
380
|
+
): {
|
|
381
|
+
rootErrors: ReadonlyArray<BatchInterruptError>
|
|
382
|
+
submissionRootErrors: ReadonlyArray<BatchInterruptError>
|
|
383
|
+
} {
|
|
384
|
+
const replaceableIncoming = incoming.filter(
|
|
385
|
+
(error) => error.source !== 'transport',
|
|
386
|
+
)
|
|
387
|
+
const isSuperseded = (candidate: BatchInterruptError): boolean =>
|
|
388
|
+
previousSubmission.includes(candidate) &&
|
|
389
|
+
replaceableIncoming.some((error) =>
|
|
390
|
+
haveSameBatchCorrelation(candidate, error),
|
|
391
|
+
)
|
|
392
|
+
const retainedRootErrors = current.filter(
|
|
393
|
+
(candidate) => !isSuperseded(candidate),
|
|
394
|
+
)
|
|
395
|
+
const retainedSubmissionRootErrors = previousSubmission.filter(
|
|
396
|
+
(candidate) =>
|
|
397
|
+
!replaceableIncoming.some((error) =>
|
|
398
|
+
haveSameBatchCorrelation(candidate, error),
|
|
399
|
+
),
|
|
400
|
+
)
|
|
401
|
+
return Object.freeze({
|
|
402
|
+
rootErrors: Object.freeze([...retainedRootErrors, ...incoming]),
|
|
403
|
+
submissionRootErrors: Object.freeze([
|
|
404
|
+
...retainedSubmissionRootErrors,
|
|
405
|
+
...replaceableIncoming,
|
|
406
|
+
]),
|
|
407
|
+
})
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function submissionErrorMatchesActiveBatch(
|
|
411
|
+
error: InterruptSubmissionError,
|
|
412
|
+
submission: InterruptManagerSubmission,
|
|
413
|
+
): boolean {
|
|
414
|
+
if (
|
|
415
|
+
error.threadId !== submission.threadId ||
|
|
416
|
+
error.interruptedRunId !== submission.interruptedRunId ||
|
|
417
|
+
error.generation !== submission.generation
|
|
418
|
+
) {
|
|
419
|
+
return false
|
|
420
|
+
}
|
|
421
|
+
const interruptIds = submission.resolutions.map(
|
|
422
|
+
(resolution) => resolution.interruptId,
|
|
423
|
+
)
|
|
424
|
+
return error.scope === 'item'
|
|
425
|
+
? interruptIds.includes(error.interruptId)
|
|
426
|
+
: haveSameInterruptIds(error.interruptIds, interruptIds)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function genericBinding(
|
|
430
|
+
interrupt: Interrupt,
|
|
431
|
+
hydration: InterruptManagerHydration,
|
|
432
|
+
candidate: InterruptBinding | undefined,
|
|
433
|
+
): InterruptBinding {
|
|
434
|
+
return cloneAndDeepFreezeJson({
|
|
435
|
+
v: INTERRUPT_BINDING_VERSION,
|
|
436
|
+
kind: 'generic',
|
|
437
|
+
interruptId: interrupt.id,
|
|
438
|
+
interruptedRunId: hydration.interruptedRunId,
|
|
439
|
+
generation: hydration.generation,
|
|
440
|
+
responseSchemaHash:
|
|
441
|
+
responseSchemaHash(interrupt) ??
|
|
442
|
+
candidate?.responseSchemaHash ??
|
|
443
|
+
'invalid',
|
|
444
|
+
...(interrupt.expiresAt !== undefined
|
|
445
|
+
? { expiresAt: interrupt.expiresAt }
|
|
446
|
+
: {}),
|
|
447
|
+
})
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function baseSnapshot(
|
|
451
|
+
item: RuntimeInterrupt,
|
|
452
|
+
hydration: InterruptManagerHydration,
|
|
453
|
+
cancel: () => void,
|
|
454
|
+
clearResolution: () => void,
|
|
455
|
+
): BoundInterruptBase {
|
|
456
|
+
const descriptor = cloneAndDeepFreezeJson(item.descriptor)
|
|
457
|
+
const errors: ReadonlyArray<ItemInterruptError> =
|
|
458
|
+
item.error === undefined
|
|
459
|
+
? Object.freeze([])
|
|
460
|
+
: Object.freeze([cloneAndDeepFreezeJson(item.error)])
|
|
461
|
+
const error = errors[0]
|
|
462
|
+
return {
|
|
463
|
+
id: descriptor.id,
|
|
464
|
+
interruptId: descriptor.id,
|
|
465
|
+
reason: descriptor.reason,
|
|
466
|
+
...(descriptor.message !== undefined
|
|
467
|
+
? { message: descriptor.message }
|
|
468
|
+
: {}),
|
|
469
|
+
...(descriptor.responseSchema !== undefined
|
|
470
|
+
? { responseSchema: descriptor.responseSchema }
|
|
471
|
+
: {}),
|
|
472
|
+
...(descriptor.expiresAt !== undefined
|
|
473
|
+
? { expiresAt: descriptor.expiresAt }
|
|
474
|
+
: {}),
|
|
475
|
+
...(descriptor.metadata !== undefined
|
|
476
|
+
? { metadata: descriptor.metadata }
|
|
477
|
+
: {}),
|
|
478
|
+
threadId: hydration.threadId,
|
|
479
|
+
interruptedRunId: hydration.interruptedRunId,
|
|
480
|
+
generation: hydration.generation,
|
|
481
|
+
status: item.status,
|
|
482
|
+
errors,
|
|
483
|
+
...(error !== undefined ? { error } : {}),
|
|
484
|
+
canResolve: item.canResolve,
|
|
485
|
+
cancel,
|
|
486
|
+
clearResolution,
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export class InterruptManager<
|
|
491
|
+
TTools extends ReadonlyArray<AnyClientTool> = ReadonlyArray<AnyClientTool>,
|
|
492
|
+
> {
|
|
493
|
+
private hydration: InterruptManagerHydration | undefined
|
|
494
|
+
private items: Array<RuntimeInterrupt> = []
|
|
495
|
+
private snapshot: ReadonlyArray<ChatInterrupt<TTools>> = Object.freeze([])
|
|
496
|
+
private rootErrors: ReadonlyArray<BatchInterruptError> = Object.freeze([])
|
|
497
|
+
private submissionRootErrors: ReadonlyArray<BatchInterruptError> =
|
|
498
|
+
Object.freeze([])
|
|
499
|
+
private state: ChatInterruptState<TTools> = Object.freeze({
|
|
500
|
+
interrupts: this.snapshot,
|
|
501
|
+
pendingInterrupts: this.snapshot,
|
|
502
|
+
interruptErrors: this.rootErrors,
|
|
503
|
+
resuming: false,
|
|
504
|
+
})
|
|
505
|
+
private activeTransaction: TransactionToken | undefined
|
|
506
|
+
private retrySubmission: InterruptManagerSubmission | undefined
|
|
507
|
+
private resuming = false
|
|
508
|
+
private tools: TTools | undefined
|
|
509
|
+
|
|
510
|
+
constructor(private readonly options: InterruptManagerOptions<TTools>) {
|
|
511
|
+
this.tools = options.tools
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
updateTools(tools: TTools): void {
|
|
515
|
+
this.tools = tools
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
hydrate(hydration: InterruptManagerHydration): void {
|
|
519
|
+
this.hydration = {
|
|
520
|
+
threadId: hydration.threadId,
|
|
521
|
+
interruptedRunId: hydration.interruptedRunId,
|
|
522
|
+
generation: hydration.generation,
|
|
523
|
+
interrupts: cloneAndDeepFreezeJson(hydration.interrupts),
|
|
524
|
+
}
|
|
525
|
+
this.items = hydration.interrupts.map((interrupt) =>
|
|
526
|
+
this.hydrateInterrupt(interrupt, hydration),
|
|
527
|
+
)
|
|
528
|
+
this.rootErrors = Object.freeze([])
|
|
529
|
+
this.submissionRootErrors = Object.freeze([])
|
|
530
|
+
this.retrySubmission = undefined
|
|
531
|
+
this.resuming = false
|
|
532
|
+
this.publish()
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
getInterrupts(): BoundInterrupts<TTools> {
|
|
536
|
+
return this.snapshot
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
getState(): ChatInterruptState<TTools> {
|
|
540
|
+
return this.state
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
getDescriptors(): ReadonlyArray<Interrupt> {
|
|
544
|
+
return this.hydration?.interrupts ?? Object.freeze([])
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
reset(options?: { preserveRootErrors?: boolean }): void {
|
|
548
|
+
this.hydration = undefined
|
|
549
|
+
this.items = []
|
|
550
|
+
this.snapshot = Object.freeze([])
|
|
551
|
+
if (options?.preserveRootErrors !== true) {
|
|
552
|
+
this.rootErrors = Object.freeze([])
|
|
553
|
+
this.submissionRootErrors = Object.freeze([])
|
|
554
|
+
}
|
|
555
|
+
this.retrySubmission = undefined
|
|
556
|
+
this.resuming = false
|
|
557
|
+
this.state = Object.freeze({
|
|
558
|
+
interrupts: this.snapshot,
|
|
559
|
+
pendingInterrupts: this.snapshot,
|
|
560
|
+
interruptErrors: this.rootErrors,
|
|
561
|
+
resuming: false,
|
|
562
|
+
})
|
|
563
|
+
this.options.onChange?.()
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
getInterruptErrors(): ReadonlyArray<BatchInterruptError> {
|
|
567
|
+
return this.rootErrors
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
getResuming(): boolean {
|
|
571
|
+
return this.resuming
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
resolve(approved: boolean): void
|
|
575
|
+
resolve(resolver: (interrupt: ChatInterrupt<TTools>) => undefined): void
|
|
576
|
+
resolve(
|
|
577
|
+
resolution: boolean | ((interrupt: ChatInterrupt<TTools>) => unknown),
|
|
578
|
+
): void {
|
|
579
|
+
this.assertRootMutable()
|
|
580
|
+
if (typeof resolution === 'boolean') {
|
|
581
|
+
this.resolveBooleanBulk(resolution)
|
|
582
|
+
return
|
|
583
|
+
}
|
|
584
|
+
this.resolveTransaction(resolution)
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
cancel(): void {
|
|
588
|
+
this.assertRootMutable()
|
|
589
|
+
this.invalidateRetry()
|
|
590
|
+
for (const item of this.items) {
|
|
591
|
+
item.validationGeneration++
|
|
592
|
+
item.resolution = Object.freeze({
|
|
593
|
+
interruptId: item.descriptor.id,
|
|
594
|
+
status: 'cancelled',
|
|
595
|
+
})
|
|
596
|
+
item.status = 'staged'
|
|
597
|
+
item.error = undefined
|
|
598
|
+
}
|
|
599
|
+
this.publish()
|
|
600
|
+
this.maybeSubmit()
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
retry(): void {
|
|
604
|
+
if (this.resuming)
|
|
605
|
+
throw new Error('Interrupt submission is already active.')
|
|
606
|
+
const submission = this.retrySubmission
|
|
607
|
+
if (!submission) {
|
|
608
|
+
this.addRootError(
|
|
609
|
+
'transport',
|
|
610
|
+
'There is no retryable interrupt submission.',
|
|
611
|
+
false,
|
|
612
|
+
)
|
|
613
|
+
return
|
|
614
|
+
}
|
|
615
|
+
this.submitBatch(submission)
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
resolveClientToolOutput(toolCallId: string, output: unknown): boolean {
|
|
619
|
+
const item = this.items.find(
|
|
620
|
+
(candidate) =>
|
|
621
|
+
(candidate.kind === 'client-tool-execution' &&
|
|
622
|
+
candidate.binding?.kind === 'client-tool-execution' &&
|
|
623
|
+
candidate.binding.toolCallId === toolCallId) ||
|
|
624
|
+
(candidate.kind === 'generic' &&
|
|
625
|
+
isClientToolExecutionReason(candidate.descriptor.reason) &&
|
|
626
|
+
candidate.descriptor.toolCallId === toolCallId &&
|
|
627
|
+
isLegacyClientToolMetadata(candidate.descriptor.metadata)),
|
|
628
|
+
)
|
|
629
|
+
if (!item) return false
|
|
630
|
+
this.resolveItem(item.descriptor.id, output)
|
|
631
|
+
return true
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
resolveToolApprovalDecision(interruptId: string, approved: boolean): boolean {
|
|
635
|
+
const item = this.items.find(
|
|
636
|
+
(candidate) =>
|
|
637
|
+
candidate.descriptor.id === interruptId &&
|
|
638
|
+
(candidate.kind === 'tool-approval' ||
|
|
639
|
+
(candidate.kind === 'generic' &&
|
|
640
|
+
candidate.descriptor.reason === 'approval_required' &&
|
|
641
|
+
isLegacyApprovalMetadata(candidate.descriptor.metadata))),
|
|
642
|
+
)
|
|
643
|
+
if (!item) return false
|
|
644
|
+
this.resolveItem(item.descriptor.id, { approved })
|
|
645
|
+
return true
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
private hydrateInterrupt(
|
|
649
|
+
descriptor: Interrupt,
|
|
650
|
+
hydration: InterruptManagerHydration,
|
|
651
|
+
): RuntimeInterrupt {
|
|
652
|
+
const interrupt = cloneAndDeepFreezeJson(descriptor)
|
|
653
|
+
const candidate = getDescriptorBinding(interrupt)
|
|
654
|
+
|
|
655
|
+
// No binding we understand, and nothing else identifying the descriptor as
|
|
656
|
+
// ours, means this interrupt was not produced by this package's resume
|
|
657
|
+
// path — a workflow engine's durable approval projected onto the same
|
|
658
|
+
// AG-UI stream, a third-party agent's pause, or a binding written at a
|
|
659
|
+
// protocol version we don't know.
|
|
660
|
+
//
|
|
661
|
+
// Do not invent a binding for it. Synthesising one would render a
|
|
662
|
+
// resolvable form whose answer is submitted against a run that has no
|
|
663
|
+
// matching pending descriptor, failing late as `unknown-interrupt` after
|
|
664
|
+
// the user has already filled it in. Surface it as unresolvable instead,
|
|
665
|
+
// so "someone else owns this pause" is visible rather than silently
|
|
666
|
+
// translated into an AI-domain interrupt.
|
|
667
|
+
//
|
|
668
|
+
// Pre-binding TanStack descriptors are still ours: they carry the legacy
|
|
669
|
+
// `metadata.kind` marker, so they keep hydrating through the generic path
|
|
670
|
+
// below.
|
|
671
|
+
if (candidate === undefined && !isLegacyInterruptMetadata(interrupt)) {
|
|
672
|
+
return {
|
|
673
|
+
descriptor: interrupt,
|
|
674
|
+
binding: undefined,
|
|
675
|
+
kind: 'unbound',
|
|
676
|
+
status: 'pending',
|
|
677
|
+
canResolve: false,
|
|
678
|
+
validationGeneration: 0,
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const correlated =
|
|
683
|
+
candidate !== undefined &&
|
|
684
|
+
candidate.interruptId === interrupt.id &&
|
|
685
|
+
candidate.interruptedRunId === hydration.interruptedRunId &&
|
|
686
|
+
candidate.generation === hydration.generation &&
|
|
687
|
+
candidate.responseSchemaHash ===
|
|
688
|
+
(responseSchemaHash(interrupt) ?? candidate.responseSchemaHash)
|
|
689
|
+
|
|
690
|
+
if (correlated && candidate.kind === 'tool-approval') {
|
|
691
|
+
const tool = this.tools?.find(
|
|
692
|
+
(configured) => configured.name === candidate.toolName,
|
|
693
|
+
)
|
|
694
|
+
// Gated on the binding and the schema hashes below, not on
|
|
695
|
+
// `interrupt.reason` — that string is free-form AG-UI text another
|
|
696
|
+
// producer can also use, so it cannot be what decides ownership.
|
|
697
|
+
if (
|
|
698
|
+
tool?.needsApproval === true &&
|
|
699
|
+
interrupt.toolCallId === candidate.toolCallId
|
|
700
|
+
) {
|
|
701
|
+
try {
|
|
702
|
+
const approval = normalizeApprovalSchema(
|
|
703
|
+
tool.approvalSchema,
|
|
704
|
+
tool.inputSchema,
|
|
705
|
+
)
|
|
706
|
+
if (
|
|
707
|
+
hashSchemaInput(tool.inputSchema) === candidate.inputSchemaHash &&
|
|
708
|
+
approval.approvalSchemaHash === candidate.approvalSchemaHash &&
|
|
709
|
+
approval.responseSchemaHash === candidate.responseSchemaHash
|
|
710
|
+
) {
|
|
711
|
+
return {
|
|
712
|
+
descriptor: interrupt,
|
|
713
|
+
binding: cloneAndDeepFreezeJson(candidate),
|
|
714
|
+
kind: 'tool-approval',
|
|
715
|
+
status: 'pending',
|
|
716
|
+
canResolve: true,
|
|
717
|
+
tool,
|
|
718
|
+
validationGeneration: 0,
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
} catch {
|
|
722
|
+
// Invalid configured schemas cannot safely grant typed hydration.
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
if (correlated && candidate.kind === 'client-tool-execution') {
|
|
728
|
+
const tool = this.tools?.find(
|
|
729
|
+
(configured) => configured.name === candidate.toolName,
|
|
730
|
+
)
|
|
731
|
+
// Binding-gated, for the same reason as tool approvals above.
|
|
732
|
+
if (
|
|
733
|
+
tool !== undefined &&
|
|
734
|
+
interrupt.toolCallId === candidate.toolCallId &&
|
|
735
|
+
hashSchemaInput(tool.outputSchema) === candidate.outputSchemaHash
|
|
736
|
+
) {
|
|
737
|
+
return {
|
|
738
|
+
descriptor: interrupt,
|
|
739
|
+
binding: cloneAndDeepFreezeJson(candidate),
|
|
740
|
+
kind: 'client-tool-execution',
|
|
741
|
+
status: 'pending',
|
|
742
|
+
canResolve: true,
|
|
743
|
+
tool,
|
|
744
|
+
validationGeneration: 0,
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
return {
|
|
750
|
+
descriptor: interrupt,
|
|
751
|
+
binding: genericBinding(interrupt, hydration, candidate),
|
|
752
|
+
kind: 'generic',
|
|
753
|
+
status: 'pending',
|
|
754
|
+
// The library no longer validates the wire response schema, so a generic
|
|
755
|
+
// item is always resolvable. The application validates the value itself.
|
|
756
|
+
canResolve: true,
|
|
757
|
+
validationGeneration: 0,
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
private buildSnapshot(
|
|
762
|
+
transaction?: TransactionToken,
|
|
763
|
+
): BoundInterrupts<TTools> {
|
|
764
|
+
const hydration = this.requireHydration()
|
|
765
|
+
// `client-tool-execution` items stay in `this.items` (they gate batch
|
|
766
|
+
// submission and are resolved internally via auto-execution / addToolResult),
|
|
767
|
+
// but they are never surfaced as public bound interrupts.
|
|
768
|
+
//
|
|
769
|
+
// Items with status `submitting` are also omitted: the resume stream is
|
|
770
|
+
// already in flight, so Approve/Deny is not actionable. Keeping them in
|
|
771
|
+
// the public list made UIs look stuck after a successful approve and
|
|
772
|
+
// blocked follow-up turns that key off `interrupts.length`.
|
|
773
|
+
const next = this.items
|
|
774
|
+
.filter(
|
|
775
|
+
(item) =>
|
|
776
|
+
item.kind !== 'client-tool-execution' && item.status !== 'submitting',
|
|
777
|
+
)
|
|
778
|
+
.map((item) => {
|
|
779
|
+
const base = baseSnapshot(
|
|
780
|
+
item,
|
|
781
|
+
hydration,
|
|
782
|
+
() => this.cancelItem(item.descriptor.id, transaction),
|
|
783
|
+
() => this.clearItem(item.descriptor.id, transaction),
|
|
784
|
+
)
|
|
785
|
+
// Not ours to resume: expose the descriptor so a UI can show the run
|
|
786
|
+
// is paused, with no `resolveInterrupt` to call.
|
|
787
|
+
if (item.kind === 'unbound' || item.binding === undefined) {
|
|
788
|
+
const snapshot: UnboundInterrupt = {
|
|
789
|
+
...base,
|
|
790
|
+
kind: 'unbound',
|
|
791
|
+
canResolve: false,
|
|
792
|
+
}
|
|
793
|
+
return Object.freeze(snapshot)
|
|
794
|
+
}
|
|
795
|
+
if (
|
|
796
|
+
item.kind === 'tool-approval' &&
|
|
797
|
+
item.binding.kind === 'tool-approval'
|
|
798
|
+
) {
|
|
799
|
+
const binding = cloneAndDeepFreezeJson(item.binding)
|
|
800
|
+
const snapshot = {
|
|
801
|
+
...base,
|
|
802
|
+
kind: 'tool-approval' as const,
|
|
803
|
+
binding,
|
|
804
|
+
toolName: item.binding.toolName,
|
|
805
|
+
toolCallId: item.binding.toolCallId,
|
|
806
|
+
originalArgs: cloneAndDeepFreezeJson(item.binding.originalArgs),
|
|
807
|
+
resolveInterrupt: (approved: boolean, options?: unknown) => {
|
|
808
|
+
const details = isUnknownObject(options) ? options : undefined
|
|
809
|
+
this.resolveItem(
|
|
810
|
+
item.descriptor.id,
|
|
811
|
+
{
|
|
812
|
+
approved,
|
|
813
|
+
...(approved && details?.['editedArgs'] !== undefined
|
|
814
|
+
? { editedArgs: details['editedArgs'] }
|
|
815
|
+
: {}),
|
|
816
|
+
...(details?.['payload'] !== undefined
|
|
817
|
+
? { payload: details['payload'] }
|
|
818
|
+
: {}),
|
|
819
|
+
},
|
|
820
|
+
transaction,
|
|
821
|
+
)
|
|
822
|
+
},
|
|
823
|
+
}
|
|
824
|
+
return Object.freeze(snapshot)
|
|
825
|
+
}
|
|
826
|
+
const boundGeneric =
|
|
827
|
+
item.binding.kind === 'generic'
|
|
828
|
+
? cloneAndDeepFreezeJson(item.binding)
|
|
829
|
+
: cloneAndDeepFreezeJson({
|
|
830
|
+
v: INTERRUPT_BINDING_VERSION,
|
|
831
|
+
kind: 'generic' as const,
|
|
832
|
+
interruptId: item.descriptor.id,
|
|
833
|
+
interruptedRunId: hydration.interruptedRunId,
|
|
834
|
+
generation: hydration.generation,
|
|
835
|
+
responseSchemaHash:
|
|
836
|
+
typeof item.binding.responseSchemaHash === 'string'
|
|
837
|
+
? item.binding.responseSchemaHash
|
|
838
|
+
: 'none',
|
|
839
|
+
})
|
|
840
|
+
const snapshot: GenericAGUIInterrupt = {
|
|
841
|
+
...base,
|
|
842
|
+
kind: 'generic',
|
|
843
|
+
binding: boundGeneric,
|
|
844
|
+
resolveInterrupt: (payload) =>
|
|
845
|
+
this.resolveItem(item.descriptor.id, payload, transaction),
|
|
846
|
+
}
|
|
847
|
+
return Object.freeze(snapshot)
|
|
848
|
+
})
|
|
849
|
+
|
|
850
|
+
// The runtime items are created only from the exact configured TTools entry
|
|
851
|
+
// selected by name. TypeScript cannot preserve that per-element lookup
|
|
852
|
+
// through Array.map, so this generic return boundary restores the proven
|
|
853
|
+
// distributive public union.
|
|
854
|
+
return Object.freeze(next) as BoundInterrupts<TTools>
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
private publish(): void {
|
|
858
|
+
if (!this.hydration) {
|
|
859
|
+
this.snapshot = Object.freeze([])
|
|
860
|
+
this.state = Object.freeze({
|
|
861
|
+
interrupts: this.snapshot,
|
|
862
|
+
pendingInterrupts: this.snapshot,
|
|
863
|
+
interruptErrors: this.rootErrors,
|
|
864
|
+
resuming: this.resuming,
|
|
865
|
+
})
|
|
866
|
+
this.options.onChange?.()
|
|
867
|
+
return
|
|
868
|
+
}
|
|
869
|
+
this.snapshot = this.buildSnapshot()
|
|
870
|
+
this.state = Object.freeze({
|
|
871
|
+
interrupts: this.snapshot,
|
|
872
|
+
pendingInterrupts: this.snapshot,
|
|
873
|
+
interruptErrors: this.rootErrors,
|
|
874
|
+
resuming: this.resuming,
|
|
875
|
+
})
|
|
876
|
+
this.options.onChange?.()
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
private resolveItem(
|
|
880
|
+
interruptId: string,
|
|
881
|
+
payload: unknown,
|
|
882
|
+
transaction?: TransactionToken,
|
|
883
|
+
): void {
|
|
884
|
+
this.assertItemMutable(transaction)
|
|
885
|
+
const item = this.findItem(interruptId)
|
|
886
|
+
this.invalidateRetry()
|
|
887
|
+
if (!item.canResolve) {
|
|
888
|
+
item.status = 'error'
|
|
889
|
+
item.error = this.itemError(
|
|
890
|
+
interruptId,
|
|
891
|
+
'invalid-response-schema',
|
|
892
|
+
'The interrupt response schema is invalid and cannot be resolved.',
|
|
893
|
+
)
|
|
894
|
+
if (!transaction) this.publish()
|
|
895
|
+
return
|
|
896
|
+
}
|
|
897
|
+
const validationGeneration = ++item.validationGeneration
|
|
898
|
+
const validation = this.validateCandidate(item, payload)
|
|
899
|
+
if (isPromiseLike(validation)) {
|
|
900
|
+
item.status = 'validating'
|
|
901
|
+
item.error = undefined
|
|
902
|
+
if (!transaction) this.publish()
|
|
903
|
+
void Promise.resolve(validation)
|
|
904
|
+
.then((result) => {
|
|
905
|
+
if (validationGeneration !== item.validationGeneration) return
|
|
906
|
+
this.applyValidation(item, result, transaction)
|
|
907
|
+
})
|
|
908
|
+
.catch((error: unknown) => {
|
|
909
|
+
if (validationGeneration !== item.validationGeneration) return
|
|
910
|
+
this.applyValidation(
|
|
911
|
+
item,
|
|
912
|
+
{
|
|
913
|
+
code: this.validationCode(item),
|
|
914
|
+
message: error instanceof Error ? error.message : String(error),
|
|
915
|
+
},
|
|
916
|
+
transaction,
|
|
917
|
+
)
|
|
918
|
+
})
|
|
919
|
+
return
|
|
920
|
+
}
|
|
921
|
+
this.applyValidation(item, validation, transaction)
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
private cancelItem(
|
|
925
|
+
interruptId: string,
|
|
926
|
+
transaction?: TransactionToken,
|
|
927
|
+
): void {
|
|
928
|
+
this.assertItemMutable(transaction)
|
|
929
|
+
const item = this.findItem(interruptId)
|
|
930
|
+
this.invalidateRetry()
|
|
931
|
+
item.validationGeneration++
|
|
932
|
+
item.resolution = Object.freeze({ interruptId, status: 'cancelled' })
|
|
933
|
+
item.status = 'staged'
|
|
934
|
+
item.error = undefined
|
|
935
|
+
if (!transaction) {
|
|
936
|
+
this.publish()
|
|
937
|
+
this.maybeSubmit()
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
private clearItem(interruptId: string, transaction?: TransactionToken): void {
|
|
942
|
+
this.assertItemMutable(transaction)
|
|
943
|
+
const item = this.findItem(interruptId)
|
|
944
|
+
this.invalidateRetry()
|
|
945
|
+
item.validationGeneration++
|
|
946
|
+
item.resolution = undefined
|
|
947
|
+
item.error = undefined
|
|
948
|
+
item.status = 'pending'
|
|
949
|
+
if (!transaction) this.publish()
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
private maybeSubmit(): void {
|
|
953
|
+
// Unbound items can never be resolved through this path — something else
|
|
954
|
+
// owns them. Including them in the completeness gate would deadlock the
|
|
955
|
+
// batch, so the run's own interrupts could never be answered once a
|
|
956
|
+
// foreign one shared the stream.
|
|
957
|
+
const ours = this.items.filter((item) => item.kind !== 'unbound')
|
|
958
|
+
if (
|
|
959
|
+
ours.length === 0 ||
|
|
960
|
+
ours.some(
|
|
961
|
+
(item) => item.resolution === undefined || item.status !== 'staged',
|
|
962
|
+
)
|
|
963
|
+
) {
|
|
964
|
+
return
|
|
965
|
+
}
|
|
966
|
+
const hydration = this.requireHydration()
|
|
967
|
+
const canonical = canonicalizeInterruptResolutions(
|
|
968
|
+
ours.map((item) => item.resolution).filter((item) => item !== undefined),
|
|
969
|
+
)
|
|
970
|
+
const submission = Object.freeze({
|
|
971
|
+
threadId: hydration.threadId,
|
|
972
|
+
interruptedRunId: hydration.interruptedRunId,
|
|
973
|
+
generation: hydration.generation,
|
|
974
|
+
resolutions: canonical.resolutions,
|
|
975
|
+
canonicalResolutions: canonical.canonicalResolutions,
|
|
976
|
+
fingerprint: canonical.fingerprint,
|
|
977
|
+
})
|
|
978
|
+
this.submitBatch(submission)
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
private applyValidation(
|
|
982
|
+
item: RuntimeInterrupt,
|
|
983
|
+
result: ValidationResult,
|
|
984
|
+
transaction?: TransactionToken,
|
|
985
|
+
): void {
|
|
986
|
+
if (!('valid' in result)) {
|
|
987
|
+
item.status = 'error'
|
|
988
|
+
const itemError = this.itemError(
|
|
989
|
+
item.descriptor.id,
|
|
990
|
+
result.code,
|
|
991
|
+
result.message,
|
|
992
|
+
result.path,
|
|
993
|
+
)
|
|
994
|
+
item.error = itemError
|
|
995
|
+
// Client-tool-execution items are hidden from the public interrupt list,
|
|
996
|
+
// so promote their validation failures onto interruptErrors for the UI.
|
|
997
|
+
if (item.kind === 'client-tool-execution') {
|
|
998
|
+
this.rootErrors = Object.freeze([
|
|
999
|
+
...this.rootErrors.filter(
|
|
1000
|
+
(error) =>
|
|
1001
|
+
!(
|
|
1002
|
+
error.code === 'item-validation-failed' &&
|
|
1003
|
+
error.interruptIds.includes(item.descriptor.id)
|
|
1004
|
+
),
|
|
1005
|
+
),
|
|
1006
|
+
Object.freeze({
|
|
1007
|
+
scope: 'batch' as const,
|
|
1008
|
+
code: 'item-validation-failed' as const,
|
|
1009
|
+
message: itemError.message,
|
|
1010
|
+
source: 'client' as const,
|
|
1011
|
+
retryable: false,
|
|
1012
|
+
interruptIds: Object.freeze([item.descriptor.id]),
|
|
1013
|
+
threadId: itemError.threadId,
|
|
1014
|
+
interruptedRunId: itemError.interruptedRunId,
|
|
1015
|
+
generation: itemError.generation,
|
|
1016
|
+
}),
|
|
1017
|
+
])
|
|
1018
|
+
}
|
|
1019
|
+
if (!transaction) this.publish()
|
|
1020
|
+
return
|
|
1021
|
+
}
|
|
1022
|
+
item.resolution = cloneAndDeepFreezeJson({
|
|
1023
|
+
interruptId: item.descriptor.id,
|
|
1024
|
+
status: 'resolved',
|
|
1025
|
+
payload: result.payload,
|
|
1026
|
+
})
|
|
1027
|
+
item.status = 'staged'
|
|
1028
|
+
item.error = undefined
|
|
1029
|
+
if (!transaction) {
|
|
1030
|
+
this.publish()
|
|
1031
|
+
this.maybeSubmit()
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
private validateCandidate(
|
|
1036
|
+
item: RuntimeInterrupt,
|
|
1037
|
+
payload: unknown,
|
|
1038
|
+
): ValidationResult | Promise<ValidationResult> {
|
|
1039
|
+
if (item.kind === 'generic') {
|
|
1040
|
+
return validateWithSchema(
|
|
1041
|
+
item.descriptor.responseSchema,
|
|
1042
|
+
payload,
|
|
1043
|
+
'invalid-payload',
|
|
1044
|
+
)
|
|
1045
|
+
}
|
|
1046
|
+
if (item.kind === 'client-tool-execution') {
|
|
1047
|
+
return validateWithSchema(
|
|
1048
|
+
item.tool?.outputSchema,
|
|
1049
|
+
payload,
|
|
1050
|
+
'invalid-tool-output',
|
|
1051
|
+
)
|
|
1052
|
+
}
|
|
1053
|
+
return this.validateApprovalCandidate(item, payload)
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
private validateApprovalCandidate(
|
|
1057
|
+
item: RuntimeInterrupt,
|
|
1058
|
+
payload: unknown,
|
|
1059
|
+
): ValidationResult | Promise<ValidationResult> {
|
|
1060
|
+
if (!isUnknownObject(payload) || typeof payload['approved'] !== 'boolean') {
|
|
1061
|
+
return {
|
|
1062
|
+
code: 'invalid-payload',
|
|
1063
|
+
message: 'Tool approval resolutions require an approved boolean.',
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
const approved = payload['approved']
|
|
1067
|
+
const editedArgs = payload['editedArgs']
|
|
1068
|
+
if (!approved && editedArgs !== undefined) {
|
|
1069
|
+
return {
|
|
1070
|
+
code: 'invalid-edited-args',
|
|
1071
|
+
message: 'Rejected tool approvals cannot edit tool arguments.',
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
if (approved && editedArgs !== undefined) {
|
|
1075
|
+
const editedValidation = validateWithSchema(
|
|
1076
|
+
item.tool?.inputSchema,
|
|
1077
|
+
editedArgs,
|
|
1078
|
+
'invalid-edited-args',
|
|
1079
|
+
)
|
|
1080
|
+
if (isPromiseLike(editedValidation)) {
|
|
1081
|
+
return Promise.resolve(editedValidation).then((result) =>
|
|
1082
|
+
'valid' in result
|
|
1083
|
+
? this.validateApprovalPayload(item, payload, result.payload)
|
|
1084
|
+
: result,
|
|
1085
|
+
)
|
|
1086
|
+
}
|
|
1087
|
+
if (!('valid' in editedValidation)) return editedValidation
|
|
1088
|
+
return this.validateApprovalPayload(
|
|
1089
|
+
item,
|
|
1090
|
+
payload,
|
|
1091
|
+
editedValidation.payload,
|
|
1092
|
+
)
|
|
1093
|
+
}
|
|
1094
|
+
return this.validateApprovalPayload(item, payload, undefined)
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
private validateApprovalPayload(
|
|
1098
|
+
item: RuntimeInterrupt,
|
|
1099
|
+
envelope: UnknownObject,
|
|
1100
|
+
validatedEditedArgs: unknown,
|
|
1101
|
+
): ValidationResult | Promise<ValidationResult> {
|
|
1102
|
+
const approved = envelope['approved'] === true
|
|
1103
|
+
const schema = this.approvalBranchSchema(item.tool, approved)
|
|
1104
|
+
const branchPayload = envelope['payload']
|
|
1105
|
+
if (schema === undefined && branchPayload !== undefined) {
|
|
1106
|
+
return {
|
|
1107
|
+
code: 'invalid-payload',
|
|
1108
|
+
message: 'This approval branch does not accept a payload.',
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
if (schema !== undefined && branchPayload === undefined) {
|
|
1112
|
+
return {
|
|
1113
|
+
code: 'invalid-payload',
|
|
1114
|
+
message: 'This approval branch requires a payload.',
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
const validation = validateWithSchema(
|
|
1118
|
+
schema,
|
|
1119
|
+
branchPayload,
|
|
1120
|
+
'invalid-payload',
|
|
1121
|
+
)
|
|
1122
|
+
const buildEnvelope = (result: ValidationResult): ValidationResult => {
|
|
1123
|
+
if (!('valid' in result)) return result
|
|
1124
|
+
return {
|
|
1125
|
+
valid: true,
|
|
1126
|
+
payload: {
|
|
1127
|
+
approved,
|
|
1128
|
+
...(validatedEditedArgs !== undefined
|
|
1129
|
+
? { editedArgs: validatedEditedArgs }
|
|
1130
|
+
: {}),
|
|
1131
|
+
...(schema !== undefined ? { payload: result.payload } : {}),
|
|
1132
|
+
},
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
return isPromiseLike(validation)
|
|
1136
|
+
? Promise.resolve(validation).then(buildEnvelope)
|
|
1137
|
+
: buildEnvelope(validation)
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
private approvalBranchSchema(
|
|
1141
|
+
tool: AnyClientTool | undefined,
|
|
1142
|
+
approved: boolean,
|
|
1143
|
+
): unknown {
|
|
1144
|
+
const approvalSchema: unknown = tool?.approvalSchema
|
|
1145
|
+
if (!isUnknownObject(approvalSchema)) return approvalSchema
|
|
1146
|
+
const hasBranches =
|
|
1147
|
+
'approve' in approvalSchema || 'reject' in approvalSchema
|
|
1148
|
+
if (!hasBranches) return approvalSchema
|
|
1149
|
+
return approved ? approvalSchema['approve'] : approvalSchema['reject']
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
private validationCode(item: RuntimeInterrupt): ItemInterruptError['code'] {
|
|
1153
|
+
return item.kind === 'client-tool-execution'
|
|
1154
|
+
? 'invalid-tool-output'
|
|
1155
|
+
: 'invalid-payload'
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
private resolveBooleanBulk(approved: boolean): void {
|
|
1159
|
+
// `client-tool-execution` items resolve out-of-band (auto execution /
|
|
1160
|
+
// addToolResult); they are transparent to the boolean shorthand. Eligibility
|
|
1161
|
+
// and resolution consider only the publicly resolvable items.
|
|
1162
|
+
const resolvable = this.items.filter(
|
|
1163
|
+
(item) => item.kind !== 'client-tool-execution',
|
|
1164
|
+
)
|
|
1165
|
+
const eligible = resolvable.every(
|
|
1166
|
+
(item) =>
|
|
1167
|
+
item.kind === 'tool-approval' &&
|
|
1168
|
+
this.approvalBranchSchema(item.tool, approved) === undefined,
|
|
1169
|
+
)
|
|
1170
|
+
if (!eligible || resolvable.length === 0) {
|
|
1171
|
+
this.addRootError(
|
|
1172
|
+
'unsupported-bulk-operation',
|
|
1173
|
+
'Boolean bulk resolution requires payloadless tool approvals.',
|
|
1174
|
+
false,
|
|
1175
|
+
)
|
|
1176
|
+
return
|
|
1177
|
+
}
|
|
1178
|
+
this.invalidateRetry()
|
|
1179
|
+
for (const item of resolvable) {
|
|
1180
|
+
item.validationGeneration++
|
|
1181
|
+
item.resolution = cloneAndDeepFreezeJson({
|
|
1182
|
+
interruptId: item.descriptor.id,
|
|
1183
|
+
status: 'resolved',
|
|
1184
|
+
payload: { approved },
|
|
1185
|
+
})
|
|
1186
|
+
item.status = 'staged'
|
|
1187
|
+
item.error = undefined
|
|
1188
|
+
}
|
|
1189
|
+
this.publish()
|
|
1190
|
+
this.maybeSubmit()
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
private resolveTransaction(
|
|
1194
|
+
resolver: (interrupt: ChatInterrupt<TTools>) => unknown,
|
|
1195
|
+
): void {
|
|
1196
|
+
const checkpoints = this.items.map<RuntimeInterruptCheckpoint>((item) => ({
|
|
1197
|
+
status: item.status,
|
|
1198
|
+
...(item.resolution !== undefined ? { resolution: item.resolution } : {}),
|
|
1199
|
+
...(item.error !== undefined ? { error: item.error } : {}),
|
|
1200
|
+
validationGeneration: item.validationGeneration,
|
|
1201
|
+
}))
|
|
1202
|
+
const token: TransactionToken = { active: true }
|
|
1203
|
+
this.activeTransaction = token
|
|
1204
|
+
const stable = this.buildSnapshot(token)
|
|
1205
|
+
let failure:
|
|
1206
|
+
| { code: BatchInterruptError['code']; message: string }
|
|
1207
|
+
| undefined
|
|
1208
|
+
try {
|
|
1209
|
+
for (const interrupt of stable) {
|
|
1210
|
+
const result = resolver(interrupt)
|
|
1211
|
+
if (result !== undefined) {
|
|
1212
|
+
failure = {
|
|
1213
|
+
code: isPromiseLike(result)
|
|
1214
|
+
? 'async-resolver'
|
|
1215
|
+
: 'inactive-transaction',
|
|
1216
|
+
message: isPromiseLike(result)
|
|
1217
|
+
? 'Interrupt transaction resolvers must be synchronous.'
|
|
1218
|
+
: 'Interrupt transaction resolvers must return literal undefined.',
|
|
1219
|
+
}
|
|
1220
|
+
break
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
if (
|
|
1224
|
+
failure === undefined &&
|
|
1225
|
+
this.items.some(
|
|
1226
|
+
(item) =>
|
|
1227
|
+
// `client-tool-execution` items are resolved out-of-band (auto
|
|
1228
|
+
// execution / addToolResult), not by this synchronous resolver, so
|
|
1229
|
+
// they don't count against transaction completeness. `maybeSubmit`
|
|
1230
|
+
// still gates the actual submission on them being resolved.
|
|
1231
|
+
item.kind !== 'client-tool-execution' &&
|
|
1232
|
+
(item.resolution === undefined || item.status !== 'staged'),
|
|
1233
|
+
)
|
|
1234
|
+
) {
|
|
1235
|
+
failure = {
|
|
1236
|
+
code: 'incomplete-batch',
|
|
1237
|
+
message: 'Interrupt transaction did not resolve every item.',
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
} catch (error) {
|
|
1241
|
+
failure = {
|
|
1242
|
+
code: 'item-validation-failed',
|
|
1243
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1244
|
+
}
|
|
1245
|
+
} finally {
|
|
1246
|
+
token.active = false
|
|
1247
|
+
this.activeTransaction = undefined
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
if (failure) {
|
|
1251
|
+
this.restoreCheckpoints(checkpoints)
|
|
1252
|
+
this.addRootError(failure.code, failure.message, false)
|
|
1253
|
+
return
|
|
1254
|
+
}
|
|
1255
|
+
this.publish()
|
|
1256
|
+
this.maybeSubmit()
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
private restoreCheckpoints(
|
|
1260
|
+
checkpoints: ReadonlyArray<RuntimeInterruptCheckpoint>,
|
|
1261
|
+
): void {
|
|
1262
|
+
this.items.forEach((item, index) => {
|
|
1263
|
+
const checkpoint = checkpoints[index]
|
|
1264
|
+
if (!checkpoint) return
|
|
1265
|
+
item.status = checkpoint.status
|
|
1266
|
+
item.resolution = checkpoint.resolution
|
|
1267
|
+
item.error = checkpoint.error
|
|
1268
|
+
item.validationGeneration = checkpoint.validationGeneration + 1
|
|
1269
|
+
})
|
|
1270
|
+
this.publish()
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
private assertItemMutable(transaction?: TransactionToken): void {
|
|
1274
|
+
if (transaction && !transaction.active) {
|
|
1275
|
+
throw new Error('Interrupt transaction is inactive.')
|
|
1276
|
+
}
|
|
1277
|
+
if (this.activeTransaction && transaction !== this.activeTransaction) {
|
|
1278
|
+
throw new Error('Interrupt transaction is inactive.')
|
|
1279
|
+
}
|
|
1280
|
+
if (this.resuming) {
|
|
1281
|
+
throw new Error('Interrupts cannot be mutated while submitting.')
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
private assertRootMutable(): void {
|
|
1286
|
+
if (this.activeTransaction) {
|
|
1287
|
+
throw new Error('Interrupt transaction is already active.')
|
|
1288
|
+
}
|
|
1289
|
+
if (this.resuming) {
|
|
1290
|
+
throw new Error('Interrupts cannot be mutated while submitting.')
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
private invalidateRetry(): void {
|
|
1295
|
+
this.retrySubmission = undefined
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
private submitBatch(submission: InterruptManagerSubmission): void {
|
|
1299
|
+
this.resuming = true
|
|
1300
|
+
this.retrySubmission = undefined
|
|
1301
|
+
for (const item of this.items) item.status = 'submitting'
|
|
1302
|
+
this.publish()
|
|
1303
|
+
void this.performSubmission(submission)
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
private async performSubmission(
|
|
1307
|
+
submission: InterruptManagerSubmission,
|
|
1308
|
+
): Promise<void> {
|
|
1309
|
+
try {
|
|
1310
|
+
await this.options.submit(submission)
|
|
1311
|
+
} catch (error) {
|
|
1312
|
+
this.handleSubmissionFailure(error, submission)
|
|
1313
|
+
} finally {
|
|
1314
|
+
this.resuming = false
|
|
1315
|
+
this.publish()
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
private handleSubmissionFailure(
|
|
1320
|
+
error: unknown,
|
|
1321
|
+
submission: InterruptManagerSubmission,
|
|
1322
|
+
): void {
|
|
1323
|
+
const errors = readSubmissionErrors(error)
|
|
1324
|
+
if (errors.length === 0) {
|
|
1325
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
1326
|
+
this.addRootError('transport', message, true, 'transport')
|
|
1327
|
+
this.retrySubmission = submission
|
|
1328
|
+
for (const item of this.items) item.status = 'error'
|
|
1329
|
+
return
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
const correlatedErrors = errors.filter((submissionError) =>
|
|
1333
|
+
submissionErrorMatchesActiveBatch(submissionError, submission),
|
|
1334
|
+
)
|
|
1335
|
+
if (correlatedErrors.length !== errors.length) {
|
|
1336
|
+
this.addRootError(
|
|
1337
|
+
'protocol',
|
|
1338
|
+
'Interrupt submission errors did not match the active batch.',
|
|
1339
|
+
false,
|
|
1340
|
+
)
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
let nonRetryable = false
|
|
1344
|
+
let retryable = false
|
|
1345
|
+
const batchErrors: Array<BatchInterruptError> = []
|
|
1346
|
+
for (const submissionError of correlatedErrors) {
|
|
1347
|
+
if (
|
|
1348
|
+
submissionError.code === 'stale' ||
|
|
1349
|
+
submissionError.code === 'expired' ||
|
|
1350
|
+
submissionError.code === 'conflict'
|
|
1351
|
+
) {
|
|
1352
|
+
nonRetryable = true
|
|
1353
|
+
}
|
|
1354
|
+
retryable ||= submissionError.retryable
|
|
1355
|
+
if (submissionError.scope === 'item') {
|
|
1356
|
+
const item = this.items.find(
|
|
1357
|
+
(candidate) =>
|
|
1358
|
+
candidate.descriptor.id === submissionError.interruptId,
|
|
1359
|
+
)
|
|
1360
|
+
if (item) {
|
|
1361
|
+
item.status = 'error'
|
|
1362
|
+
item.error = cloneAndDeepFreezeJson(submissionError)
|
|
1363
|
+
}
|
|
1364
|
+
} else {
|
|
1365
|
+
batchErrors.push(cloneAndDeepFreezeJson(submissionError))
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
const mergedBatchErrors = mergeSubmissionBatchErrors(
|
|
1369
|
+
this.rootErrors,
|
|
1370
|
+
this.submissionRootErrors,
|
|
1371
|
+
batchErrors,
|
|
1372
|
+
)
|
|
1373
|
+
this.rootErrors = mergedBatchErrors.rootErrors
|
|
1374
|
+
this.submissionRootErrors = mergedBatchErrors.submissionRootErrors
|
|
1375
|
+
for (const item of this.items) {
|
|
1376
|
+
if (item.status === 'submitting') item.status = 'error'
|
|
1377
|
+
}
|
|
1378
|
+
this.retrySubmission = retryable && !nonRetryable ? submission : undefined
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
private addRootError(
|
|
1382
|
+
code: BatchInterruptError['code'],
|
|
1383
|
+
message: string,
|
|
1384
|
+
retryable: boolean,
|
|
1385
|
+
source: BatchInterruptError['source'] = 'client',
|
|
1386
|
+
): void {
|
|
1387
|
+
const hydration = this.requireHydration()
|
|
1388
|
+
this.rootErrors = Object.freeze([
|
|
1389
|
+
...this.rootErrors,
|
|
1390
|
+
Object.freeze({
|
|
1391
|
+
scope: 'batch' as const,
|
|
1392
|
+
code,
|
|
1393
|
+
message,
|
|
1394
|
+
source,
|
|
1395
|
+
retryable,
|
|
1396
|
+
interruptIds: Object.freeze(
|
|
1397
|
+
this.items.map((item) => item.descriptor.id),
|
|
1398
|
+
),
|
|
1399
|
+
threadId: hydration.threadId,
|
|
1400
|
+
interruptedRunId: hydration.interruptedRunId,
|
|
1401
|
+
generation: hydration.generation,
|
|
1402
|
+
}),
|
|
1403
|
+
])
|
|
1404
|
+
this.publish()
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
private findItem(interruptId: string): RuntimeInterrupt {
|
|
1408
|
+
const item = this.items.find(
|
|
1409
|
+
(candidate) => candidate.descriptor.id === interruptId,
|
|
1410
|
+
)
|
|
1411
|
+
if (!item) throw new Error(`Unknown interrupt: ${interruptId}`)
|
|
1412
|
+
return item
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
private requireHydration(): InterruptManagerHydration {
|
|
1416
|
+
if (!this.hydration) throw new Error('InterruptManager is not hydrated.')
|
|
1417
|
+
return this.hydration
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
private itemError(
|
|
1421
|
+
interruptId: string,
|
|
1422
|
+
code: ItemInterruptError['code'],
|
|
1423
|
+
message: string,
|
|
1424
|
+
path?: ReadonlyArray<string | number>,
|
|
1425
|
+
): ItemInterruptError {
|
|
1426
|
+
const hydration = this.requireHydration()
|
|
1427
|
+
return Object.freeze({
|
|
1428
|
+
scope: 'item',
|
|
1429
|
+
interruptId,
|
|
1430
|
+
code,
|
|
1431
|
+
message,
|
|
1432
|
+
...(path !== undefined ? { path: Object.freeze([...path]) } : {}),
|
|
1433
|
+
source: 'client',
|
|
1434
|
+
retryable: false,
|
|
1435
|
+
threadId: hydration.threadId,
|
|
1436
|
+
interruptedRunId: hydration.interruptedRunId,
|
|
1437
|
+
generation: hydration.generation,
|
|
1438
|
+
})
|
|
1439
|
+
}
|
|
1440
|
+
}
|