@nifrajs/agent 4.0.2 → 5.1.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/LICENSE +21 -0
- package/README.md +8 -1
- 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 +15 -2
- package/dist/mount.d.ts.map +1 -1
- package/dist/mount.js +94 -17
- 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 +109 -18
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 on a public AG-UI route, so it cannot approve its own call. `mountA2A` defaults to `"port"`.
|
|
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
|
@@ -11,13 +11,17 @@
|
|
|
11
11
|
* - `POST {path}` body `{ input, turnId?, resume? }`:
|
|
12
12
|
* - `Accept: text/event-stream` -> SSE: one `event: step` per {@link AgentStepEvidence}, then a final
|
|
13
13
|
* `event: result` carrying the projected run result, then the stream closes.
|
|
14
|
-
* - otherwise -> a single JSON body: the projected run result.
|
|
14
|
+
* - otherwise -> a single JSON body: the projected run result. A run that throws answers
|
|
15
|
+
* `500 { error: "run_failed" }`, or `409 { error: "resume_unavailable" }` when the POST resumed a turn.
|
|
15
16
|
* - `resume` is `{ continuation, approval? }` (see {@link AgentTurnInput}); the runtime keeps state
|
|
16
17
|
* token-only, so a suspended tool's input must be replayed by the caller in `continuation.input`.
|
|
18
|
+
* Return `resumeApproval: "port"` from `ports` when the caller must not approve its own tool calls.
|
|
17
19
|
* - With an `evidenceLog` configured, `step` frames carry `id: <seq>` and a dropped SSE connection
|
|
18
20
|
* is resumable: re-POST the same `turnId` with a `Last-Event-ID` header to replay the missed
|
|
19
21
|
* evidence and rejoin the still-running turn (or receive the stored terminal frame) - the run is
|
|
20
22
|
* never re-executed. Without the log the header is ignored and every POST starts a run.
|
|
23
|
+
* - One SSE response emits at most `maxOutputBytes` (default 4 MiB), so a client that stops reading
|
|
24
|
+
* cannot make the server queue a whole run; past it the response ends with `event: error`.
|
|
21
25
|
* - The seam performs no authentication or authorization. Wrap it with the app's own route guards, and
|
|
22
26
|
* scope the store/model returned by `ports` to the caller (RLS born in at the data layer). With an
|
|
23
27
|
* `evidenceLog`, set `evidenceOwner` too: without it a turn id is a bearer capability, and any
|
|
@@ -53,6 +57,8 @@ import {
|
|
|
53
57
|
|
|
54
58
|
const DEFAULT_PATH = "/agent"
|
|
55
59
|
const DEFAULT_MAX_BODY_BYTES = 1_000_000
|
|
60
|
+
const DEFAULT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024
|
|
61
|
+
const MAX_OUTPUT_BYTES = 64 * 1024 * 1024
|
|
56
62
|
const PENDING_KINDS: readonly AgentPendingKind[] = ["approval", "budget", "model", "cancelled"]
|
|
57
63
|
const LAST_EVENT_ID_PATTERN = /^\d{1,15}$/
|
|
58
64
|
const TURN_ID_PATTERN = /^[a-zA-Z0-9._:-]{1,128}$/
|
|
@@ -80,9 +86,17 @@ export interface MountAgentOptions<
|
|
|
80
86
|
readonly maxBodyBytes?: number
|
|
81
87
|
/** Prototype-poisoning policy for the framing lane. Default `"reject"`. */
|
|
82
88
|
readonly protoPoisoning?: ProtoPoisoning
|
|
89
|
+
/**
|
|
90
|
+
* Maximum total UTF-8 bytes one SSE response emits. Default 4 MiB. Past it the response ends with an
|
|
91
|
+
* `error` frame (`output_limit`); with an `evidenceLog` the run keeps recording and a reconnect with
|
|
92
|
+
* `Last-Event-ID` reads on from where this response stopped, otherwise the run is cancelled.
|
|
93
|
+
*/
|
|
94
|
+
readonly maxOutputBytes?: number
|
|
83
95
|
/**
|
|
84
96
|
* Build the ports for one request - the model, durable state store, approval transport, capabilities,
|
|
85
97
|
* and budgets. Receives the route context so the caller can scope every port to the request subject.
|
|
98
|
+
* Throw a `Response` (a 401 with its challenge, a 403) to answer the request with it; any other throw
|
|
99
|
+
* is a fault and reaches the app's error handling.
|
|
86
100
|
*/
|
|
87
101
|
readonly ports: (c: AgentRouteContext) => AgentPorts | Promise<AgentPorts>
|
|
88
102
|
/**
|
|
@@ -93,7 +107,8 @@ export interface MountAgentOptions<
|
|
|
93
107
|
/**
|
|
94
108
|
* Who a turn's recorded evidence belongs to - the caller's user or tenant id. Turns are recorded and
|
|
95
109
|
* replayed under it, so a reconnect or a reused `turnId` from another caller finds nothing of this
|
|
96
|
-
* caller's turns. It runs before a replay is served; throw to refuse the request
|
|
110
|
+
* caller's turns. It runs before a replay is served; throw to refuse the request with
|
|
111
|
+
* `403 { error: "forbidden" }`, or throw a `Response` to answer with it instead.
|
|
97
112
|
*/
|
|
98
113
|
readonly evidenceOwner?: (c: AgentRouteContext) => string | Promise<string>
|
|
99
114
|
}
|
|
@@ -107,16 +122,23 @@ export function mountAgent<
|
|
|
107
122
|
const maxBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
|
|
108
123
|
const proto = options.protoPoisoning ?? "reject"
|
|
109
124
|
const runOptions = options.maxTurns === undefined ? {} : { maxTurns: options.maxTurns }
|
|
125
|
+
const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES
|
|
126
|
+
if (
|
|
127
|
+
!Number.isSafeInteger(maxOutputBytes) ||
|
|
128
|
+
maxOutputBytes < 1024 ||
|
|
129
|
+
maxOutputBytes > MAX_OUTPUT_BYTES
|
|
130
|
+
)
|
|
131
|
+
throw new RangeError(`mountAgent maxOutputBytes must be between 1024 and ${MAX_OUTPUT_BYTES}`)
|
|
110
132
|
|
|
111
133
|
app.post(path, (c) =>
|
|
112
134
|
readBodyFramed<Response>(
|
|
113
135
|
c.req,
|
|
114
136
|
maxBytes,
|
|
115
137
|
proto,
|
|
116
|
-
(parsed) => execute(c, parsed, options, runOptions),
|
|
138
|
+
(parsed) => execute(c, parsed, options, runOptions, maxOutputBytes),
|
|
117
139
|
(rejection) => render(rejection),
|
|
118
140
|
() => render(plainError(400, "bad_request")),
|
|
119
|
-
).catch(
|
|
141
|
+
).catch(passThrownResponse),
|
|
120
142
|
)
|
|
121
143
|
}
|
|
122
144
|
|
|
@@ -125,6 +147,7 @@ async function execute<InputSchema extends StandardSchemaV1, OutputSchema extend
|
|
|
125
147
|
parsed: unknown,
|
|
126
148
|
options: MountAgentOptions<InputSchema, OutputSchema>,
|
|
127
149
|
runOptions: { readonly maxTurns?: number },
|
|
150
|
+
maxOutputBytes: number,
|
|
128
151
|
): Promise<Response> {
|
|
129
152
|
const body = asRecord(parsed)
|
|
130
153
|
if (body === undefined) return jsonResponse(400, { error: "body_must_be_object" })
|
|
@@ -135,10 +158,17 @@ async function execute<InputSchema extends StandardSchemaV1, OutputSchema extend
|
|
|
135
158
|
const turnId = turnIdRaw ?? crypto.randomUUID()
|
|
136
159
|
|
|
137
160
|
const resume = parseResume(body.resume)
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
161
|
+
let log = options.evidenceLog
|
|
162
|
+
if (log !== undefined && options.evidenceOwner !== undefined) {
|
|
163
|
+
let owner: string
|
|
164
|
+
try {
|
|
165
|
+
owner = await options.evidenceOwner(c)
|
|
166
|
+
} catch (err) {
|
|
167
|
+
if (err instanceof Response) return err
|
|
168
|
+
return jsonResponse(403, { error: "forbidden" })
|
|
169
|
+
}
|
|
170
|
+
log = scopeAgentEvidenceLog(log, owner)
|
|
171
|
+
}
|
|
142
172
|
const wantsSse = (c.req.headers.get("accept") ?? "").includes("text/event-stream")
|
|
143
173
|
|
|
144
174
|
// A reconnect replays recorded evidence and rejoins the turn; it never starts a second run.
|
|
@@ -148,7 +178,7 @@ async function execute<InputSchema extends StandardSchemaV1, OutputSchema extend
|
|
|
148
178
|
return jsonResponse(400, { error: "invalid_last_event_id" })
|
|
149
179
|
const replay = await log.replay(turnId, Number(lastEventId))
|
|
150
180
|
if (replay === undefined) return jsonResponse(409, { error: "replay_unavailable" })
|
|
151
|
-
return sseReplayResponse(turnId, replay)
|
|
181
|
+
return sseReplayResponse(turnId, replay, maxOutputBytes)
|
|
152
182
|
}
|
|
153
183
|
|
|
154
184
|
const basePorts = await options.ports(c)
|
|
@@ -187,10 +217,11 @@ async function execute<InputSchema extends StandardSchemaV1, OutputSchema extend
|
|
|
187
217
|
} catch (err) {
|
|
188
218
|
await finishQuietly(log, turnId, { event: "error", data: { turnId, error: "run_failed" } })
|
|
189
219
|
if (err instanceof BadTurnId) return jsonResponse(400, { error: "invalid_turn_id" })
|
|
190
|
-
return jsonResponse(409, { error: "resume_unavailable" })
|
|
220
|
+
if (resume !== undefined) return jsonResponse(409, { error: "resume_unavailable" })
|
|
221
|
+
return jsonResponse(500, { error: "run_failed" })
|
|
191
222
|
}
|
|
192
223
|
}
|
|
193
|
-
return sseResponse(turnId, start, log)
|
|
224
|
+
return sseResponse(turnId, start, log, maxOutputBytes)
|
|
194
225
|
}
|
|
195
226
|
|
|
196
227
|
function sseResponse<Output>(
|
|
@@ -200,6 +231,7 @@ function sseResponse<Output>(
|
|
|
200
231
|
disconnect: AbortSignal | undefined,
|
|
201
232
|
) => Promise<AgentRunResult<Output>>,
|
|
202
233
|
log: AgentEvidenceLog | undefined,
|
|
234
|
+
maxOutputBytes: number,
|
|
203
235
|
): Response {
|
|
204
236
|
const stream = createAgentEvidenceStream()
|
|
205
237
|
// `id:` frames are only meaningful when a log can serve the reconnect they invite.
|
|
@@ -210,9 +242,18 @@ function sseResponse<Output>(
|
|
|
210
242
|
let detached = false
|
|
211
243
|
const body = new ReadableStream<Uint8Array>({
|
|
212
244
|
async start(controller) {
|
|
213
|
-
const deliver = sseSender(controller)
|
|
245
|
+
const deliver = sseSender(controller, maxOutputBytes)
|
|
214
246
|
const send: typeof deliver = (event, data, id) => {
|
|
215
|
-
if (
|
|
247
|
+
if (detached) return
|
|
248
|
+
try {
|
|
249
|
+
deliver(event, data, id)
|
|
250
|
+
} catch (error) {
|
|
251
|
+
if (!(error instanceof SseOutputLimitError)) throw error
|
|
252
|
+
// The budget belongs to this response, not to the logged run's authoritative result.
|
|
253
|
+
detached = true
|
|
254
|
+
disconnect?.abort()
|
|
255
|
+
endOverBudget(deliver, controller, turnId)
|
|
256
|
+
}
|
|
216
257
|
}
|
|
217
258
|
const run = start(stream, disconnect?.signal)
|
|
218
259
|
// The evidence stream must terminate whether the run resolves or throws, or the `for await`
|
|
@@ -254,10 +295,26 @@ function withDisconnect(
|
|
|
254
295
|
return AbortSignal.any([signal, disconnect])
|
|
255
296
|
}
|
|
256
297
|
|
|
257
|
-
function sseReplayResponse(
|
|
298
|
+
function sseReplayResponse(
|
|
299
|
+
turnId: string,
|
|
300
|
+
replay: AgentEvidenceReplay,
|
|
301
|
+
maxOutputBytes: number,
|
|
302
|
+
): Response {
|
|
303
|
+
let detached = false
|
|
258
304
|
const body = new ReadableStream<Uint8Array>({
|
|
259
305
|
async start(controller) {
|
|
260
|
-
const
|
|
306
|
+
const deliver = sseSender(controller, maxOutputBytes)
|
|
307
|
+
const send: typeof deliver = (event, data, id) => {
|
|
308
|
+
if (detached) return
|
|
309
|
+
try {
|
|
310
|
+
deliver(event, data, id)
|
|
311
|
+
} catch (error) {
|
|
312
|
+
if (!(error instanceof SseOutputLimitError)) throw error
|
|
313
|
+
detached = true
|
|
314
|
+
void replay.live?.return?.()
|
|
315
|
+
endOverBudget(deliver, controller, turnId)
|
|
316
|
+
}
|
|
317
|
+
}
|
|
261
318
|
try {
|
|
262
319
|
for (const evidence of replay.evidence) send("step", evidence, evidence.seq)
|
|
263
320
|
if (replay.live !== undefined)
|
|
@@ -268,21 +325,49 @@ function sseReplayResponse(turnId: string, replay: AgentEvidenceReplay): Respons
|
|
|
268
325
|
} catch {
|
|
269
326
|
send("error", { turnId, error: "run_failed" })
|
|
270
327
|
} finally {
|
|
271
|
-
controller.close()
|
|
328
|
+
if (!detached) controller.close()
|
|
272
329
|
}
|
|
273
330
|
},
|
|
331
|
+
cancel() {
|
|
332
|
+
detached = true
|
|
333
|
+
// Only this view detaches; the logged run keeps recording for the next reconnect.
|
|
334
|
+
void replay.live?.return?.()
|
|
335
|
+
},
|
|
274
336
|
})
|
|
275
337
|
return sseHeaders(body)
|
|
276
338
|
}
|
|
277
339
|
|
|
340
|
+
type SseSend = (event: string, data: unknown, id?: number) => void
|
|
341
|
+
|
|
342
|
+
class SseOutputLimitError extends Error {}
|
|
343
|
+
|
|
278
344
|
function sseSender(
|
|
279
345
|
controller: ReadableStreamDefaultController<Uint8Array>,
|
|
280
|
-
|
|
346
|
+
maxOutputBytes: number,
|
|
347
|
+
): SseSend {
|
|
281
348
|
const encoder = new TextEncoder()
|
|
349
|
+
let emittedBytes = 0
|
|
282
350
|
return (event, data, id) => {
|
|
283
351
|
const head = id === undefined ? "" : `id: ${id}\n`
|
|
284
|
-
|
|
352
|
+
const frame = encoder.encode(`${head}event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
|
|
353
|
+
if (frame.byteLength > maxOutputBytes || emittedBytes > maxOutputBytes - frame.byteLength)
|
|
354
|
+
throw new SseOutputLimitError("mountAgent SSE output limit exceeded")
|
|
355
|
+
emittedBytes += frame.byteLength
|
|
356
|
+
controller.enqueue(frame)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function endOverBudget(
|
|
361
|
+
deliver: SseSend,
|
|
362
|
+
controller: ReadableStreamDefaultController<Uint8Array>,
|
|
363
|
+
turnId: string,
|
|
364
|
+
): void {
|
|
365
|
+
try {
|
|
366
|
+
deliver("error", { turnId, error: "output_limit" })
|
|
367
|
+
} catch {
|
|
368
|
+
// A fully spent budget cannot carry the error frame; closing still ends this response.
|
|
285
369
|
}
|
|
370
|
+
controller.close()
|
|
286
371
|
}
|
|
287
372
|
|
|
288
373
|
function sseHeaders(body: ReadableStream<Uint8Array>): Response {
|
|
@@ -380,6 +465,12 @@ function jsonResponse(status: number, body: unknown): Response {
|
|
|
380
465
|
})
|
|
381
466
|
}
|
|
382
467
|
|
|
468
|
+
/** A thrown `Response` is the app answering for itself; anything else is a fault for its error lane. */
|
|
469
|
+
function passThrownResponse(err: unknown): Response {
|
|
470
|
+
if (err instanceof Response) return err
|
|
471
|
+
throw err
|
|
472
|
+
}
|
|
473
|
+
|
|
383
474
|
function render(response: Response | ResponseResult): Response {
|
|
384
475
|
return response instanceof Response ? response : toResponse(response, EMPTY_RESPONSE_CONTROLS)
|
|
385
476
|
}
|