@nifrajs/agent 4.0.0 → 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/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
- const MAX_EVIDENCE = 512
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
- async function settleAttempt(
345
- gateway: ModelGateway,
346
- request: ModelGatewayAttemptRequest,
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<ModelGatewayRawResult> {
350
- const before = timedFailure(request.signal, deadlineAt, now)
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 operation = Promise.resolve()
357
- .then(() => gateway.complete(request))
358
- .then(parseModelGatewayResult, () => ({
359
- ok: false as const,
360
- error: { code: "internal" as const },
361
- }))
362
- const races: Promise<ModelGatewayRawResult>[] = [operation]
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
- timer = setTimeout(() => resolve({ ok: false, error: { code: "timeout" } }), remaining)
367
- }),
368
- )
369
- }
370
- if (!request.signal.aborted) {
371
- races.push(
372
- new Promise((resolve) => {
373
- abort = () => resolve({ ok: false, error: { code: "cancelled" } })
374
- request.signal.addEventListener("abort", abort!, { once: true })
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) request.signal.removeEventListener("abort", abort)
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, signal, envelope },
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 output = await request.parser.parse(parsed.output)
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
- resume.approval === undefined || pending.kind !== "approval"
1024
+ callerDecision === undefined || pending.kind !== "approval"
1002
1025
  ? ports
1003
- : {
1004
- ...ports,
1005
- approval: { request: () => ({ status: "approved", approval: resume.approval! }) },
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 (!detached) deliver(event, data, id)
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(turnId: string, replay: AgentEvidenceReplay): Response {
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 send = sseSender(controller)
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
- ): (event: string, data: unknown, id?: number) => void {
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
- controller.enqueue(encoder.encode(`${head}event: ${event}\ndata: ${JSON.stringify(data)}\n\n`))
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 {