@nifrajs/agent 4.0.2 → 5.0.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 +5 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +11 -4
- package/dist/context.js.map +1 -1
- package/dist/events.d.ts +7 -0
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +15 -1
- package/dist/events.js.map +1 -1
- package/dist/gateway.d.ts.map +1 -1
- package/dist/gateway.js +69 -22
- package/dist/gateway.js.map +1 -1
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -6
- package/dist/index.js.map +1 -1
- package/dist/mount.d.ts +9 -0
- package/dist/mount.d.ts.map +1 -1
- package/dist/mount.js +70 -12
- package/dist/mount.js.map +1 -1
- package/package.json +2 -2
- package/src/context.ts +14 -4
- package/src/events.ts +22 -1
- package/src/gateway.ts +86 -26
- package/src/index.ts +36 -6
- package/src/mount.ts +83 -10
package/src/gateway.ts
CHANGED
|
@@ -27,7 +27,10 @@ const ERROR_CODES: ReadonlySet<string> = new Set(MODEL_GATEWAY_ERROR_CODES)
|
|
|
27
27
|
const TOKEN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
|
|
28
28
|
const MAX_ATTEMPTS = 512
|
|
29
29
|
const MAX_TOKENS = 10_000_000
|
|
30
|
-
|
|
30
|
+
// An attempt records at most four entries plus one fallback; the run adds one terminal entry.
|
|
31
|
+
const MAX_EVIDENCE = MAX_ATTEMPTS * 5 + 1
|
|
32
|
+
// setTimeout fires at once for any delay past 2^31 - 1 ms (about 24.8 days).
|
|
33
|
+
const MAX_TIMER_MS = 2_147_483_647
|
|
31
34
|
|
|
32
35
|
export interface ModelRoute {
|
|
33
36
|
/** Caller-owned opaque route identifier. It is not a provider or model name. */
|
|
@@ -341,44 +344,79 @@ function timedFailure(
|
|
|
341
344
|
return undefined
|
|
342
345
|
}
|
|
343
346
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
+
type Deadlined<T> =
|
|
348
|
+
| { readonly settled: true; readonly value: T }
|
|
349
|
+
| { readonly settled: false; readonly error: ModelGatewayError }
|
|
350
|
+
|
|
351
|
+
/** Settle `work`, unless the deadline passes or `signal` aborts first. A rejection passes through. */
|
|
352
|
+
async function withinDeadline<T>(
|
|
353
|
+
work: Promise<T>,
|
|
354
|
+
signal: AbortSignal,
|
|
347
355
|
deadlineAt: number | undefined,
|
|
348
356
|
now: () => number,
|
|
349
|
-
): Promise<
|
|
350
|
-
|
|
351
|
-
if (before !== undefined) return { ok: false, error: before }
|
|
357
|
+
): Promise<Deadlined<T>> {
|
|
358
|
+
if (signal.aborted) return { settled: false, error: { code: "cancelled" } }
|
|
352
359
|
const remaining = deadlineAt === undefined ? undefined : Math.max(0, deadlineAt - now())
|
|
353
360
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
354
361
|
let abort: (() => void) | undefined
|
|
355
362
|
try {
|
|
356
|
-
const
|
|
357
|
-
.then(() =>
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
})
|
|
362
|
-
|
|
363
|
+
const races: Promise<Deadlined<T>>[] = [
|
|
364
|
+
work.then((value) => ({ settled: true as const, value })),
|
|
365
|
+
new Promise((resolve) => {
|
|
366
|
+
abort = () => resolve({ settled: false, error: { code: "cancelled" } })
|
|
367
|
+
signal.addEventListener("abort", abort, { once: true })
|
|
368
|
+
}),
|
|
369
|
+
]
|
|
363
370
|
if (remaining !== undefined) {
|
|
364
371
|
races.push(
|
|
365
372
|
new Promise((resolve) => {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
373
|
+
let left = remaining
|
|
374
|
+
const arm = (): void => {
|
|
375
|
+
const delay = Math.min(left, MAX_TIMER_MS)
|
|
376
|
+
left -= delay
|
|
377
|
+
timer = setTimeout(
|
|
378
|
+
() => (left > 0 ? arm() : resolve({ settled: false, error: { code: "timeout" } })),
|
|
379
|
+
delay,
|
|
380
|
+
)
|
|
381
|
+
}
|
|
382
|
+
arm()
|
|
375
383
|
}),
|
|
376
384
|
)
|
|
377
385
|
}
|
|
378
386
|
return await Promise.race(races)
|
|
379
387
|
} finally {
|
|
380
388
|
if (timer !== undefined) clearTimeout(timer)
|
|
381
|
-
if (abort !== undefined)
|
|
389
|
+
if (abort !== undefined) signal.removeEventListener("abort", abort)
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function settleAttempt(
|
|
394
|
+
gateway: ModelGateway,
|
|
395
|
+
request: Omit<ModelGatewayAttemptRequest, "signal">,
|
|
396
|
+
signal: AbortSignal,
|
|
397
|
+
deadlineAt: number | undefined,
|
|
398
|
+
now: () => number,
|
|
399
|
+
): Promise<ModelGatewayRawResult> {
|
|
400
|
+
const before = timedFailure(signal, deadlineAt, now)
|
|
401
|
+
if (before !== undefined) return { ok: false, error: before }
|
|
402
|
+
// Each attempt has its own signal, aborted when the attempt stops being waited for, so an adapter
|
|
403
|
+
// that honours it stops working on a request whose result would be discarded.
|
|
404
|
+
const attempt = new AbortController()
|
|
405
|
+
const cancel = (): void => attempt.abort()
|
|
406
|
+
signal.addEventListener("abort", cancel, { once: true })
|
|
407
|
+
try {
|
|
408
|
+
const operation = Promise.resolve()
|
|
409
|
+
.then(() => gateway.complete({ ...request, signal: attempt.signal }))
|
|
410
|
+
.then(parseModelGatewayResult, () => ({
|
|
411
|
+
ok: false as const,
|
|
412
|
+
error: { code: "internal" as const },
|
|
413
|
+
}))
|
|
414
|
+
const outcome = await withinDeadline(operation, signal, deadlineAt, now)
|
|
415
|
+
if (outcome.settled) return outcome.value
|
|
416
|
+
attempt.abort()
|
|
417
|
+
return { ok: false, error: outcome.error }
|
|
418
|
+
} finally {
|
|
419
|
+
signal.removeEventListener("abort", cancel)
|
|
382
420
|
}
|
|
383
421
|
}
|
|
384
422
|
|
|
@@ -431,7 +469,8 @@ export async function runModelGateway<Input, Output>(
|
|
|
431
469
|
})
|
|
432
470
|
const raw = await settleAttempt(
|
|
433
471
|
gateway,
|
|
434
|
-
{ routeId: route, input: request.input,
|
|
472
|
+
{ routeId: route, input: request.input, envelope },
|
|
473
|
+
signal,
|
|
435
474
|
policy.deadlineAt,
|
|
436
475
|
checked.now,
|
|
437
476
|
)
|
|
@@ -461,8 +500,29 @@ export async function runModelGateway<Input, Output>(
|
|
|
461
500
|
outcome: "passed",
|
|
462
501
|
})
|
|
463
502
|
if (request.parser !== undefined) {
|
|
503
|
+
const parser = request.parser
|
|
504
|
+
// The deadline covers parsing too: a success is only returned while it is still in time.
|
|
505
|
+
const stopped = (error: ModelGatewayError) => {
|
|
506
|
+
addEvidence(evidence, {
|
|
507
|
+
kind: "attempt",
|
|
508
|
+
routeId: route,
|
|
509
|
+
attempt: attempts,
|
|
510
|
+
outcome: "failed",
|
|
511
|
+
code: error.code,
|
|
512
|
+
})
|
|
513
|
+
return terminal(error, route, attempts, usage, evidence)
|
|
514
|
+
}
|
|
464
515
|
try {
|
|
465
|
-
const
|
|
516
|
+
const parsing = await withinDeadline(
|
|
517
|
+
Promise.resolve().then(() => parser.parse(parsed.output)),
|
|
518
|
+
signal,
|
|
519
|
+
policy.deadlineAt,
|
|
520
|
+
checked.now,
|
|
521
|
+
)
|
|
522
|
+
if (!parsing.settled) return stopped(parsing.error)
|
|
523
|
+
const late = timedFailure(signal, policy.deadlineAt, checked.now)
|
|
524
|
+
if (late !== undefined) return stopped(late)
|
|
525
|
+
const output = parsing.value
|
|
466
526
|
addEvidence(evidence, {
|
|
467
527
|
kind: "parse",
|
|
468
528
|
routeId: route,
|
package/src/index.ts
CHANGED
|
@@ -275,6 +275,8 @@ export interface AgentApprovalPort {
|
|
|
275
275
|
readonly signal: AbortSignal
|
|
276
276
|
/** Wall-clock budget reserved for this approval hop and the following tool execution. */
|
|
277
277
|
readonly deadline?: RequestBudget
|
|
278
|
+
/** The decision the resuming caller sent, under `resumeApproval: "port"`. Evidence, not a verdict. */
|
|
279
|
+
readonly callerDecision?: ToolApproval
|
|
278
280
|
}): AgentApprovalResult | PromiseLike<AgentApprovalResult>
|
|
279
281
|
}
|
|
280
282
|
|
|
@@ -454,6 +456,13 @@ export interface AgentPorts {
|
|
|
454
456
|
readonly idempotency?: ToolIdempotencyStore
|
|
455
457
|
readonly state?: AgentStateStore
|
|
456
458
|
readonly approval?: AgentApprovalPort
|
|
459
|
+
/**
|
|
460
|
+
* Who decides a step that suspended for approval when the run resumes. `"caller"` (the default)
|
|
461
|
+
* takes `resume.approval` as the decision. `"port"` asks `approval` again and hands it the caller's
|
|
462
|
+
* decision as `callerDecision`; set it when the resuming caller is the party asking for the tool,
|
|
463
|
+
* as under A2A or a public AG-UI route, so it cannot approve its own call.
|
|
464
|
+
*/
|
|
465
|
+
readonly resumeApproval?: "caller" | "port"
|
|
457
466
|
readonly telemetry?: AgentTelemetryPort
|
|
458
467
|
/** Receives model deltas when the model port streams. Transient - nothing here is persisted. */
|
|
459
468
|
readonly deltas?: AgentDeltaSink
|
|
@@ -621,7 +630,19 @@ export async function turn<
|
|
|
621
630
|
if (signal.aborted) return suspended(state, { kind: "cancelled", effectId: newId() }, "cancelled")
|
|
622
631
|
|
|
623
632
|
if (state.status === "suspended") {
|
|
624
|
-
if (input.resume !== undefined) {
|
|
633
|
+
if (input.resume !== undefined && state.pending?.tool === undefined) {
|
|
634
|
+
// A model, budget or cancelled suspension has nothing to replay: echoing its continuation back
|
|
635
|
+
// retries the model step, as omitting `resume` does. Any other continuation is refused.
|
|
636
|
+
const pending = state.pending
|
|
637
|
+
const continuation = input.resume.continuation
|
|
638
|
+
if (
|
|
639
|
+
pending === undefined ||
|
|
640
|
+
continuation.tool !== undefined ||
|
|
641
|
+
continuation.kind !== pending.kind ||
|
|
642
|
+
continuation.effectId !== pending.effectId
|
|
643
|
+
)
|
|
644
|
+
throw new AgentResumeMismatchError()
|
|
645
|
+
} else if (input.resume !== undefined) {
|
|
625
646
|
return resumeTool(
|
|
626
647
|
definition,
|
|
627
648
|
state,
|
|
@@ -997,13 +1018,22 @@ async function resumeTool<
|
|
|
997
1018
|
)
|
|
998
1019
|
// The caller's decision answers only a step that suspended to await one; a step suspended for budget
|
|
999
1020
|
// or cancellation asks the approval port as it would have.
|
|
1021
|
+
const callerDecision = resume.approval
|
|
1022
|
+
const port = ports.approval
|
|
1000
1023
|
const approvalPorts: AgentPorts =
|
|
1001
|
-
|
|
1024
|
+
callerDecision === undefined || pending.kind !== "approval"
|
|
1002
1025
|
? ports
|
|
1003
|
-
:
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1026
|
+
: ports.resumeApproval !== "port"
|
|
1027
|
+
? {
|
|
1028
|
+
...ports,
|
|
1029
|
+
approval: { request: () => ({ status: "approved", approval: callerDecision }) },
|
|
1030
|
+
}
|
|
1031
|
+
: port === undefined
|
|
1032
|
+
? ports
|
|
1033
|
+
: {
|
|
1034
|
+
...ports,
|
|
1035
|
+
approval: { request: (input) => port.request({ ...input, callerDecision }) },
|
|
1036
|
+
}
|
|
1007
1037
|
return executeToolStep(
|
|
1008
1038
|
{ ...state, status: "ready" },
|
|
1009
1039
|
tool,
|
package/src/mount.ts
CHANGED
|
@@ -14,10 +14,13 @@
|
|
|
14
14
|
* - otherwise -> a single JSON body: the projected run result.
|
|
15
15
|
* - `resume` is `{ continuation, approval? }` (see {@link AgentTurnInput}); the runtime keeps state
|
|
16
16
|
* token-only, so a suspended tool's input must be replayed by the caller in `continuation.input`.
|
|
17
|
+
* Return `resumeApproval: "port"` from `ports` when the caller must not approve its own tool calls.
|
|
17
18
|
* - With an `evidenceLog` configured, `step` frames carry `id: <seq>` and a dropped SSE connection
|
|
18
19
|
* is resumable: re-POST the same `turnId` with a `Last-Event-ID` header to replay the missed
|
|
19
20
|
* evidence and rejoin the still-running turn (or receive the stored terminal frame) - the run is
|
|
20
21
|
* never re-executed. Without the log the header is ignored and every POST starts a run.
|
|
22
|
+
* - One SSE response emits at most `maxOutputBytes` (default 4 MiB), so a client that stops reading
|
|
23
|
+
* cannot make the server queue a whole run; past it the response ends with `event: error`.
|
|
21
24
|
* - The seam performs no authentication or authorization. Wrap it with the app's own route guards, and
|
|
22
25
|
* scope the store/model returned by `ports` to the caller (RLS born in at the data layer). With an
|
|
23
26
|
* `evidenceLog`, set `evidenceOwner` too: without it a turn id is a bearer capability, and any
|
|
@@ -53,6 +56,8 @@ import {
|
|
|
53
56
|
|
|
54
57
|
const DEFAULT_PATH = "/agent"
|
|
55
58
|
const DEFAULT_MAX_BODY_BYTES = 1_000_000
|
|
59
|
+
const DEFAULT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024
|
|
60
|
+
const MAX_OUTPUT_BYTES = 64 * 1024 * 1024
|
|
56
61
|
const PENDING_KINDS: readonly AgentPendingKind[] = ["approval", "budget", "model", "cancelled"]
|
|
57
62
|
const LAST_EVENT_ID_PATTERN = /^\d{1,15}$/
|
|
58
63
|
const TURN_ID_PATTERN = /^[a-zA-Z0-9._:-]{1,128}$/
|
|
@@ -80,6 +85,12 @@ export interface MountAgentOptions<
|
|
|
80
85
|
readonly maxBodyBytes?: number
|
|
81
86
|
/** Prototype-poisoning policy for the framing lane. Default `"reject"`. */
|
|
82
87
|
readonly protoPoisoning?: ProtoPoisoning
|
|
88
|
+
/**
|
|
89
|
+
* Maximum total UTF-8 bytes one SSE response emits. Default 4 MiB. Past it the response ends with an
|
|
90
|
+
* `error` frame (`output_limit`); with an `evidenceLog` the run keeps recording and a reconnect with
|
|
91
|
+
* `Last-Event-ID` reads on from where this response stopped, otherwise the run is cancelled.
|
|
92
|
+
*/
|
|
93
|
+
readonly maxOutputBytes?: number
|
|
83
94
|
/**
|
|
84
95
|
* Build the ports for one request - the model, durable state store, approval transport, capabilities,
|
|
85
96
|
* and budgets. Receives the route context so the caller can scope every port to the request subject.
|
|
@@ -107,13 +118,20 @@ export function mountAgent<
|
|
|
107
118
|
const maxBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
|
|
108
119
|
const proto = options.protoPoisoning ?? "reject"
|
|
109
120
|
const runOptions = options.maxTurns === undefined ? {} : { maxTurns: options.maxTurns }
|
|
121
|
+
const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES
|
|
122
|
+
if (
|
|
123
|
+
!Number.isSafeInteger(maxOutputBytes) ||
|
|
124
|
+
maxOutputBytes < 1024 ||
|
|
125
|
+
maxOutputBytes > MAX_OUTPUT_BYTES
|
|
126
|
+
)
|
|
127
|
+
throw new RangeError(`mountAgent maxOutputBytes must be between 1024 and ${MAX_OUTPUT_BYTES}`)
|
|
110
128
|
|
|
111
129
|
app.post(path, (c) =>
|
|
112
130
|
readBodyFramed<Response>(
|
|
113
131
|
c.req,
|
|
114
132
|
maxBytes,
|
|
115
133
|
proto,
|
|
116
|
-
(parsed) => execute(c, parsed, options, runOptions),
|
|
134
|
+
(parsed) => execute(c, parsed, options, runOptions, maxOutputBytes),
|
|
117
135
|
(rejection) => render(rejection),
|
|
118
136
|
() => render(plainError(400, "bad_request")),
|
|
119
137
|
).catch(() => render(plainError(400, "bad_request"))),
|
|
@@ -125,6 +143,7 @@ async function execute<InputSchema extends StandardSchemaV1, OutputSchema extend
|
|
|
125
143
|
parsed: unknown,
|
|
126
144
|
options: MountAgentOptions<InputSchema, OutputSchema>,
|
|
127
145
|
runOptions: { readonly maxTurns?: number },
|
|
146
|
+
maxOutputBytes: number,
|
|
128
147
|
): Promise<Response> {
|
|
129
148
|
const body = asRecord(parsed)
|
|
130
149
|
if (body === undefined) return jsonResponse(400, { error: "body_must_be_object" })
|
|
@@ -148,7 +167,7 @@ async function execute<InputSchema extends StandardSchemaV1, OutputSchema extend
|
|
|
148
167
|
return jsonResponse(400, { error: "invalid_last_event_id" })
|
|
149
168
|
const replay = await log.replay(turnId, Number(lastEventId))
|
|
150
169
|
if (replay === undefined) return jsonResponse(409, { error: "replay_unavailable" })
|
|
151
|
-
return sseReplayResponse(turnId, replay)
|
|
170
|
+
return sseReplayResponse(turnId, replay, maxOutputBytes)
|
|
152
171
|
}
|
|
153
172
|
|
|
154
173
|
const basePorts = await options.ports(c)
|
|
@@ -190,7 +209,7 @@ async function execute<InputSchema extends StandardSchemaV1, OutputSchema extend
|
|
|
190
209
|
return jsonResponse(409, { error: "resume_unavailable" })
|
|
191
210
|
}
|
|
192
211
|
}
|
|
193
|
-
return sseResponse(turnId, start, log)
|
|
212
|
+
return sseResponse(turnId, start, log, maxOutputBytes)
|
|
194
213
|
}
|
|
195
214
|
|
|
196
215
|
function sseResponse<Output>(
|
|
@@ -200,6 +219,7 @@ function sseResponse<Output>(
|
|
|
200
219
|
disconnect: AbortSignal | undefined,
|
|
201
220
|
) => Promise<AgentRunResult<Output>>,
|
|
202
221
|
log: AgentEvidenceLog | undefined,
|
|
222
|
+
maxOutputBytes: number,
|
|
203
223
|
): Response {
|
|
204
224
|
const stream = createAgentEvidenceStream()
|
|
205
225
|
// `id:` frames are only meaningful when a log can serve the reconnect they invite.
|
|
@@ -210,9 +230,18 @@ function sseResponse<Output>(
|
|
|
210
230
|
let detached = false
|
|
211
231
|
const body = new ReadableStream<Uint8Array>({
|
|
212
232
|
async start(controller) {
|
|
213
|
-
const deliver = sseSender(controller)
|
|
233
|
+
const deliver = sseSender(controller, maxOutputBytes)
|
|
214
234
|
const send: typeof deliver = (event, data, id) => {
|
|
215
|
-
if (
|
|
235
|
+
if (detached) return
|
|
236
|
+
try {
|
|
237
|
+
deliver(event, data, id)
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (!(error instanceof SseOutputLimitError)) throw error
|
|
240
|
+
// The budget belongs to this response, not to the logged run's authoritative result.
|
|
241
|
+
detached = true
|
|
242
|
+
disconnect?.abort()
|
|
243
|
+
endOverBudget(deliver, controller, turnId)
|
|
244
|
+
}
|
|
216
245
|
}
|
|
217
246
|
const run = start(stream, disconnect?.signal)
|
|
218
247
|
// The evidence stream must terminate whether the run resolves or throws, or the `for await`
|
|
@@ -254,10 +283,26 @@ function withDisconnect(
|
|
|
254
283
|
return AbortSignal.any([signal, disconnect])
|
|
255
284
|
}
|
|
256
285
|
|
|
257
|
-
function sseReplayResponse(
|
|
286
|
+
function sseReplayResponse(
|
|
287
|
+
turnId: string,
|
|
288
|
+
replay: AgentEvidenceReplay,
|
|
289
|
+
maxOutputBytes: number,
|
|
290
|
+
): Response {
|
|
291
|
+
let detached = false
|
|
258
292
|
const body = new ReadableStream<Uint8Array>({
|
|
259
293
|
async start(controller) {
|
|
260
|
-
const
|
|
294
|
+
const deliver = sseSender(controller, maxOutputBytes)
|
|
295
|
+
const send: typeof deliver = (event, data, id) => {
|
|
296
|
+
if (detached) return
|
|
297
|
+
try {
|
|
298
|
+
deliver(event, data, id)
|
|
299
|
+
} catch (error) {
|
|
300
|
+
if (!(error instanceof SseOutputLimitError)) throw error
|
|
301
|
+
detached = true
|
|
302
|
+
void replay.live?.return?.()
|
|
303
|
+
endOverBudget(deliver, controller, turnId)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
261
306
|
try {
|
|
262
307
|
for (const evidence of replay.evidence) send("step", evidence, evidence.seq)
|
|
263
308
|
if (replay.live !== undefined)
|
|
@@ -268,21 +313,49 @@ function sseReplayResponse(turnId: string, replay: AgentEvidenceReplay): Respons
|
|
|
268
313
|
} catch {
|
|
269
314
|
send("error", { turnId, error: "run_failed" })
|
|
270
315
|
} finally {
|
|
271
|
-
controller.close()
|
|
316
|
+
if (!detached) controller.close()
|
|
272
317
|
}
|
|
273
318
|
},
|
|
319
|
+
cancel() {
|
|
320
|
+
detached = true
|
|
321
|
+
// Only this view detaches; the logged run keeps recording for the next reconnect.
|
|
322
|
+
void replay.live?.return?.()
|
|
323
|
+
},
|
|
274
324
|
})
|
|
275
325
|
return sseHeaders(body)
|
|
276
326
|
}
|
|
277
327
|
|
|
328
|
+
type SseSend = (event: string, data: unknown, id?: number) => void
|
|
329
|
+
|
|
330
|
+
class SseOutputLimitError extends Error {}
|
|
331
|
+
|
|
278
332
|
function sseSender(
|
|
279
333
|
controller: ReadableStreamDefaultController<Uint8Array>,
|
|
280
|
-
|
|
334
|
+
maxOutputBytes: number,
|
|
335
|
+
): SseSend {
|
|
281
336
|
const encoder = new TextEncoder()
|
|
337
|
+
let emittedBytes = 0
|
|
282
338
|
return (event, data, id) => {
|
|
283
339
|
const head = id === undefined ? "" : `id: ${id}\n`
|
|
284
|
-
|
|
340
|
+
const frame = encoder.encode(`${head}event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
|
|
341
|
+
if (frame.byteLength > maxOutputBytes || emittedBytes > maxOutputBytes - frame.byteLength)
|
|
342
|
+
throw new SseOutputLimitError("mountAgent SSE output limit exceeded")
|
|
343
|
+
emittedBytes += frame.byteLength
|
|
344
|
+
controller.enqueue(frame)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function endOverBudget(
|
|
349
|
+
deliver: SseSend,
|
|
350
|
+
controller: ReadableStreamDefaultController<Uint8Array>,
|
|
351
|
+
turnId: string,
|
|
352
|
+
): void {
|
|
353
|
+
try {
|
|
354
|
+
deliver("error", { turnId, error: "output_limit" })
|
|
355
|
+
} catch {
|
|
356
|
+
// A fully spent budget cannot carry the error frame; closing still ends this response.
|
|
285
357
|
}
|
|
358
|
+
controller.close()
|
|
286
359
|
}
|
|
287
360
|
|
|
288
361
|
function sseHeaders(body: ReadableStream<Uint8Array>): Response {
|