@nifrajs/ag-ui 3.2.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/index.ts ADDED
@@ -0,0 +1,817 @@
1
+ /**
2
+ * `@nifrajs/ag-ui` - mount a nifra agent as an AG-UI (Agent-User Interaction protocol) endpoint.
3
+ *
4
+ * The package is a protocol bridge over the `@nifrajs/agent` runner, mirroring the `mountAgent`
5
+ * seam: one HTTP POST accepting the AG-UI `RunAgentInput` body, the bounded runner in the middle,
6
+ * a stream of AG-UI events out. The request body is read through core's single bounded,
7
+ * proto-guarded framing lane (`@nifrajs/core/edge-kit`); the model, durable state store, and
8
+ * approval transport arrive through a per-request `ports` factory and never live in this package.
9
+ *
10
+ * Wire contract (default path `/agui`):
11
+ * - `POST {path}` body `RunAgentInput` (`threadId` and `runId` required) -> SSE where each `data:`
12
+ * field is one AG-UI event:
13
+ * - `RUN_STARTED`, then `CUSTOM { name: "nifra.turn", value: { turnId } }` announcing the
14
+ * runner's turn id.
15
+ * - Step evidence maps to `TOOL_CALL_START`/`TOOL_CALL_END` for tool effects, followed by a
16
+ * `TOOL_CALL_RESULT` whose `content` is the token-only outcome (`{ outcome, code? }` - the
17
+ * runtime never carries tool payloads), and `STEP_STARTED`/`STEP_FINISHED` for everything
18
+ * else. Evidence-derived events carry the evidence `timestamp`.
19
+ * - A model port that streams (`request.onDelta`) turns into live token frames:
20
+ * `TEXT_MESSAGE_START`/`_CONTENT` for text deltas, `REASONING_*` for reasoning deltas, and
21
+ * `TOOL_CALL_START` + `TOOL_CALL_ARGS` for the tool call being formed - the following tool
22
+ * evidence closes that same call (`TOOL_CALL_END` + `TOOL_CALL_RESULT`) instead of opening a
23
+ * second one. When any text was streamed, the terminal `TEXT_MESSAGE_*` block is suppressed -
24
+ * the streamed text IS the assistant message, so a streaming port must stream all
25
+ * user-visible text. A non-streaming port keeps the single terminal
26
+ * `TEXT_MESSAGE_START`/`_CONTENT`/`_END` block. `usage` deltas are summed per
27
+ * `(provider, model)` across the run's model decisions and stamped as the spec's
28
+ * `usage: TokenUsage[]` array on the terminal `RUN_FINISHED` event (success and interrupt
29
+ * alike) - they never produce a frame of their own. Deltas are transient: a `Last-Event-ID`
30
+ * replay carries evidence frames and the stored (unsuppressed) terminal events only - the
31
+ * stored `RUN_FINISHED` keeps its `usage`.
32
+ * - The `ports` factory receives `(c, run)` where `run.sharedState` is the run's
33
+ * `AgentSharedState` channel: `body.state` seeds the document (announced as an upfront
34
+ * `STATE_SNAPSHOT` when present), every `patch` streams as `STATE_DELTA` (RFC 6902 ops), and
35
+ * a first patch without a seeded document announces itself as a `STATE_SNAPSHOT`.
36
+ * - A completed run ends `RUN_FINISHED` with `result` and `outcome: { type: "success" }`, after
37
+ * an optional `MESSAGES_SNAPSHOT` (see `emitMessagesSnapshot`); a failed one ends `RUN_ERROR`.
38
+ * - A suspended run (approval, budget, model retry, max turns) emits
39
+ * `CUSTOM { name: "nifra.pending", value: { turnId, reason, continuation } }` and then
40
+ * `RUN_FINISHED` with `outcome: { type: "interrupt", interrupts: [{ id: turnId, reason,
41
+ * toolCallId?, responseSchema, metadata: { turnId, continuation } }] }`. Resume with the
42
+ * spec `resume` array - `[{ interruptId: turnId, status, payload: { continuation,
43
+ * approval? } }]` where `payload.continuation` echoes `metadata.continuation` (the runtime is
44
+ * stateless, so the client must send it back) - or with the legacy
45
+ * `forwardedProps.resume` (`{ continuation, approval? }`) plus `forwardedProps.turnId`. A
46
+ * `status: "cancelled"` entry without an explicit approval resumes as a denial. The runtime
47
+ * keeps state token-only, so a suspended tool's input must be replayed in
48
+ * `continuation.input`. A resume that fails validation is ignored and the POST starts a
49
+ * fresh run.
50
+ * - Agent input is `forwardedProps.input` when present, otherwise the content of the last
51
+ * `role: "user"` message.
52
+ * - With an `evidenceLog` configured, evidence-derived frames carry SSE `id: <seq>` and a dropped
53
+ * connection is resumable: re-POST the same body with a `Last-Event-ID` header to replay the
54
+ * missed events and rejoin the still-running turn - the run is never re-executed. Without the
55
+ * log the header is ignored and every POST starts a run.
56
+ * - The seam performs no authentication or authorization. Wrap it with the app's own route guards,
57
+ * and scope the store/model returned by `ports` to the caller.
58
+ */
59
+
60
+ import {
61
+ type AgentDefinition,
62
+ type AgentDeltaSink,
63
+ type AgentModelDelta,
64
+ type AgentPendingKind,
65
+ type AgentPorts,
66
+ type AgentRunResult,
67
+ type AgentSharedState,
68
+ type AgentStepEvidence,
69
+ type AgentTurnInput,
70
+ combineAgentDeltaSinks,
71
+ combineAgentTelemetry,
72
+ createAgentSharedState,
73
+ createAgentState,
74
+ resumeAgent,
75
+ runAgent,
76
+ } from "@nifrajs/agent"
77
+ import {
78
+ type AgentEvidenceLog,
79
+ type AgentEvidenceReplay,
80
+ createAgentEvidenceStream,
81
+ } from "@nifrajs/agent/events"
82
+ import {
83
+ EMPTY_RESPONSE_CONTROLS,
84
+ type ProtoPoisoning,
85
+ plainError,
86
+ type ResponseResult,
87
+ readBodyFramed,
88
+ toResponse,
89
+ } from "@nifrajs/core/edge-kit"
90
+ import type { StandardSchemaV1 } from "@nifrajs/core/schema"
91
+
92
+ const DEFAULT_PATH = "/agui"
93
+ const DEFAULT_MAX_BODY_BYTES = 1_000_000
94
+ const PENDING_KINDS: readonly AgentPendingKind[] = ["approval", "budget", "model", "cancelled"]
95
+ const TURN_ID_PATTERN = /^[a-zA-Z0-9._:-]{1,128}$/
96
+ const LAST_EVENT_ID_PATTERN = /^\d{1,15}$/
97
+
98
+ /** The structural slice of a route context the seam needs. */
99
+ export interface AgUIRouteContext {
100
+ readonly req: Request
101
+ }
102
+
103
+ /** The structural slice of a nifra server `mountAgUI` needs. */
104
+ export interface AgUIMountableApp {
105
+ post(path: string, handler: (c: AgUIRouteContext) => Response | Promise<Response>): unknown
106
+ }
107
+
108
+ /** Per-run context handed to the `ports` factory alongside the route context. */
109
+ export interface AgUIRunContext {
110
+ /** The runner turn id this request resolves to (fresh run or resume target). */
111
+ readonly turnId: string
112
+ /**
113
+ * The run's shared UI state channel. `body.state` seeds the document; patches applied by any
114
+ * port stream to the client as `STATE_SNAPSHOT`/`STATE_DELTA`. Transient - never persisted.
115
+ */
116
+ readonly sharedState: AgentSharedState
117
+ }
118
+
119
+ export interface MountAgUIOptions<
120
+ InputSchema extends StandardSchemaV1,
121
+ OutputSchema extends StandardSchemaV1,
122
+ > {
123
+ readonly agent: AgentDefinition<InputSchema, OutputSchema>
124
+ /** Endpoint path. Default `/agui`. */
125
+ readonly path?: string
126
+ /** Bounded run length passed to the runner. */
127
+ readonly maxTurns?: number
128
+ /** Maximum request body size in bytes. Default 1_000_000. */
129
+ readonly maxBodyBytes?: number
130
+ /** Prototype-poisoning policy for the framing lane. Default `"reject"`. */
131
+ readonly protoPoisoning?: ProtoPoisoning
132
+ /**
133
+ * Build the ports for one request - the model, durable state store, approval transport,
134
+ * capabilities, and budgets. Receives the route context so the caller can scope every port to
135
+ * the request subject, and the run context carrying the turn id and the shared UI state channel.
136
+ */
137
+ readonly ports: (c: AgUIRouteContext, run: AgUIRunContext) => AgentPorts | Promise<AgentPorts>
138
+ /**
139
+ * Evidence log making the SSE stream resumable via `Last-Event-ID`. The in-memory reference
140
+ * (`createMemoryAgentEvidenceLog`) is single-process; a durable log is an adapter concern.
141
+ */
142
+ readonly evidenceLog?: AgentEvidenceLog
143
+ /**
144
+ * Emit a `MESSAGES_SNAPSHOT` (the request's `messages` plus the assistant output message)
145
+ * before `RUN_FINISHED` on a successful completion. Default `false`: the snapshot echoes
146
+ * client-sent message payloads, and terminal events are persisted to the evidence log when one
147
+ * is configured - leave it off unless the client relies on an authoritative snapshot.
148
+ */
149
+ readonly emitMessagesSnapshot?: boolean
150
+ }
151
+
152
+ /** Mount a single agent as an AG-UI `POST {path}` SSE endpoint. */
153
+ export function mountAgUI<
154
+ InputSchema extends StandardSchemaV1,
155
+ OutputSchema extends StandardSchemaV1,
156
+ >(app: AgUIMountableApp, options: MountAgUIOptions<InputSchema, OutputSchema>): void {
157
+ const path = options.path ?? DEFAULT_PATH
158
+ const maxBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
159
+ const proto = options.protoPoisoning ?? "reject"
160
+ const runOptions = options.maxTurns === undefined ? {} : { maxTurns: options.maxTurns }
161
+
162
+ app.post(path, (c) =>
163
+ readBodyFramed<Response>(
164
+ c.req,
165
+ maxBytes,
166
+ proto,
167
+ (parsed) => execute(c, parsed, options, runOptions),
168
+ (rejection) => render(rejection),
169
+ () => render(plainError(400, "bad_request")),
170
+ ).catch(() => render(plainError(400, "bad_request"))),
171
+ )
172
+ }
173
+
174
+ async function execute<InputSchema extends StandardSchemaV1, OutputSchema extends StandardSchemaV1>(
175
+ c: AgUIRouteContext,
176
+ parsed: unknown,
177
+ options: MountAgUIOptions<InputSchema, OutputSchema>,
178
+ runOptions: { readonly maxTurns?: number },
179
+ ): Promise<Response> {
180
+ const body = asRecord(parsed)
181
+ const threadId = body === undefined ? undefined : body.threadId
182
+ const runId = body === undefined ? undefined : body.runId
183
+ if (body === undefined || typeof threadId !== "string" || typeof runId !== "string")
184
+ return jsonResponse(400, { error: "invalid_run_agent_input" })
185
+
186
+ const forwarded = asRecord(body.forwardedProps) ?? {}
187
+ const forwardedTurnId = typeof forwarded.turnId === "string" ? forwarded.turnId : undefined
188
+ if (forwardedTurnId !== undefined && !TURN_ID_PATTERN.test(forwardedTurnId))
189
+ return jsonResponse(400, { error: "invalid_turn_id" })
190
+ // Legacy resume names the turn in forwardedProps; spec resume names it via the interrupt id.
191
+ const entry = parseResumeEntry(body.resume)
192
+ const turnId =
193
+ forwardedTurnId ?? entry?.turnId ?? (TURN_ID_PATTERN.test(runId) ? runId : crypto.randomUUID())
194
+
195
+ const input = Object.hasOwn(forwarded, "input") ? forwarded.input : lastUserMessage(body.messages)
196
+ const resume = parseResume(forwarded.resume) ?? entry?.resume
197
+ const log = options.evidenceLog
198
+ const identity: RunIdentity = { threadId, runId, turnId }
199
+
200
+ // A reconnect replays recorded evidence and rejoins the turn; it never starts a second run.
201
+ const lastEventId = c.req.headers.get("last-event-id")
202
+ if (log !== undefined && lastEventId !== null) {
203
+ if (!LAST_EVENT_ID_PATTERN.test(lastEventId))
204
+ return jsonResponse(400, { error: "invalid_last_event_id" })
205
+ const replay = await log.replay(turnId, Number(lastEventId))
206
+ if (replay === undefined) return jsonResponse(409, { error: "replay_unavailable" })
207
+ return sseReplayResponse(identity, replay)
208
+ }
209
+
210
+ // `body.state` seeds the run's shared UI state document; patches stream as STATE_DELTA.
211
+ const sharedState = createAgentSharedState<unknown>(body.state === undefined ? {} : body.state)
212
+ const basePorts = await options.ports(c, { turnId, sharedState })
213
+
214
+ const start = (
215
+ telemetry: AgentPorts["telemetry"],
216
+ deltas: AgentDeltaSink,
217
+ ): Promise<AgentRunResult<unknown>> => {
218
+ // Compose rather than replace: the SSE evidence stream must not displace a telemetry port or
219
+ // delta sink the caller injected through `ports`.
220
+ const combined = combineAgentTelemetry(basePorts.telemetry, log?.open(turnId), telemetry)
221
+ const sinks = combineAgentDeltaSinks(basePorts.deltas, deltas)
222
+ const ports = {
223
+ ...basePorts,
224
+ ...(combined === undefined ? {} : { telemetry: combined }),
225
+ ...(sinks === undefined ? {} : { deltas: sinks }),
226
+ }
227
+ if (resume !== undefined) {
228
+ return resumeAgent(options.agent, turnId, { value: input, resume }, ports, runOptions)
229
+ }
230
+ return runAgent(options.agent, { value: input }, ports, {
231
+ ...runOptions,
232
+ state: createAgentState(turnId),
233
+ })
234
+ }
235
+ const snapshotMessages = options.emitMessagesSnapshot === true ? body.messages : undefined
236
+ return sseResponse(identity, start, log, snapshotMessages, {
237
+ channel: sharedState,
238
+ announced: body.state !== undefined,
239
+ })
240
+ }
241
+
242
+ interface RunIdentity {
243
+ readonly threadId: string
244
+ readonly runId: string
245
+ readonly turnId: string
246
+ }
247
+
248
+ function sseResponse(
249
+ identity: RunIdentity,
250
+ start: (
251
+ telemetry: AgentPorts["telemetry"],
252
+ deltas: AgentDeltaSink,
253
+ ) => Promise<AgentRunResult<unknown>>,
254
+ log: AgentEvidenceLog | undefined,
255
+ snapshotMessages: unknown,
256
+ state: { readonly channel: AgentSharedState; readonly announced: boolean },
257
+ ): Response {
258
+ const stream = createAgentEvidenceStream()
259
+ // `id:` frames are only meaningful when a log can serve the reconnect they invite.
260
+ const withIds = log !== undefined
261
+ const body = new ReadableStream<Uint8Array>({
262
+ async start(controller) {
263
+ const send = sseSender(controller)
264
+ const projector = createDeltaProjector(send, identity.turnId)
265
+ const run = start(stream, projector.sink)
266
+ // The evidence stream must terminate whether the run resolves or throws, or the `for await`
267
+ // below would hang; the run result (or the error) is still reported in-band after it drains.
268
+ run
269
+ .then(
270
+ () => {},
271
+ () => {},
272
+ )
273
+ .finally(() => stream.complete())
274
+ send({ type: "RUN_STARTED", threadId: identity.threadId, runId: identity.runId })
275
+ send({ type: "CUSTOM", name: "nifra.turn", value: { turnId: identity.turnId } })
276
+ let announced = state.announced
277
+ if (announced)
278
+ send({ type: "STATE_SNAPSHOT", snapshot: state.channel.snapshot(), timestamp: Date.now() })
279
+ // A first patch on an unseeded document announces the whole document instead of a delta
280
+ // against a base the client never saw.
281
+ const unsubscribe = state.channel.subscribe((ops) => {
282
+ if (!announced) {
283
+ announced = true
284
+ send({
285
+ type: "STATE_SNAPSHOT",
286
+ snapshot: state.channel.snapshot(),
287
+ timestamp: Date.now(),
288
+ })
289
+ return
290
+ }
291
+ send({ type: "STATE_DELTA", delta: ops, timestamp: Date.now() })
292
+ })
293
+ try {
294
+ for await (const evidence of stream) {
295
+ const openToolCallId = projector.adopt(evidence)
296
+ sendEvidence(send, evidence, identity.turnId, withIds, openToolCallId)
297
+ }
298
+ const { streamedText, usage } = projector.finish()
299
+ const events = terminalEvents(identity, await run, snapshotMessages, usage)
300
+ // Store the terminal events before delivering them, so a client that misses them can
301
+ // replay. The stored form is always the unsuppressed one - a replayed stream saw no
302
+ // deltas, so it needs the full terminal text block.
303
+ await finishQuietly(log, identity.turnId, { events })
304
+ for (const event of events) {
305
+ // Streamed text IS the assistant message; re-sending it as a terminal block would
306
+ // duplicate it on the live client.
307
+ if (streamedText && String(event.type).startsWith("TEXT_MESSAGE_")) continue
308
+ send(event)
309
+ }
310
+ } catch {
311
+ projector.finish()
312
+ const events = [{ type: "RUN_ERROR", message: "run_failed", timestamp: Date.now() }]
313
+ await finishQuietly(log, identity.turnId, { events })
314
+ for (const event of events) send(event)
315
+ } finally {
316
+ unsubscribe()
317
+ controller.close()
318
+ }
319
+ },
320
+ })
321
+ return sseHeaders(body)
322
+ }
323
+
324
+ function sseReplayResponse(identity: RunIdentity, replay: AgentEvidenceReplay): Response {
325
+ const body = new ReadableStream<Uint8Array>({
326
+ async start(controller) {
327
+ const send = sseSender(controller)
328
+ send({ type: "RUN_STARTED", threadId: identity.threadId, runId: identity.runId })
329
+ send({ type: "CUSTOM", name: "nifra.turn", value: { turnId: identity.turnId } })
330
+ try {
331
+ for (const evidence of replay.evidence) sendEvidence(send, evidence, identity.turnId, true)
332
+ if (replay.live !== undefined)
333
+ for await (const evidence of replay.live)
334
+ sendEvidence(send, evidence, identity.turnId, true)
335
+ const events = asTerminalEvents(await replay.result)
336
+ if (events === undefined) send({ type: "RUN_ERROR", message: "run_failed" })
337
+ else for (const event of events) send(event)
338
+ } catch {
339
+ send({ type: "RUN_ERROR", message: "run_failed" })
340
+ } finally {
341
+ controller.close()
342
+ }
343
+ },
344
+ })
345
+ return sseHeaders(body)
346
+ }
347
+
348
+ type SseSend = (event: Record<string, unknown>, id?: number) => void
349
+
350
+ function sseSender(controller: ReadableStreamDefaultController<Uint8Array>): SseSend {
351
+ const encoder = new TextEncoder()
352
+ return (event, id) => {
353
+ const head = id === undefined ? "" : `id: ${id}\n`
354
+ controller.enqueue(encoder.encode(`${head}data: ${JSON.stringify(event)}\n\n`))
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Emit one evidence item's events, stamping the `seq` only on the item's LAST frame: SSE
360
+ * `Last-Event-ID` points at the last frame received, so a partially delivered multi-event item
361
+ * (a TOOL_CALL_START without its END) is replayed whole on reconnect instead of losing its tail.
362
+ */
363
+ function sendEvidence(
364
+ send: SseSend,
365
+ evidence: AgentStepEvidence,
366
+ turnId: string,
367
+ withIds: boolean,
368
+ openToolCallId?: string,
369
+ ): void {
370
+ const events = evidenceEvents(evidence, turnId, openToolCallId)
371
+ for (let index = 0; index < events.length; index += 1) {
372
+ const event = events[index] as Record<string, unknown>
373
+ if (withIds && index === events.length - 1) send(event, evidence.seq)
374
+ else send(event)
375
+ }
376
+ }
377
+
378
+ function sseHeaders(body: ReadableStream<Uint8Array>): Response {
379
+ return new Response(body, {
380
+ status: 200,
381
+ headers: {
382
+ "content-type": "text/event-stream; charset=utf-8",
383
+ "cache-control": "no-cache, no-transform",
384
+ connection: "keep-alive",
385
+ },
386
+ })
387
+ }
388
+
389
+ /**
390
+ * One aggregated token-usage entry in the spec's `RUN_FINISHED.usage` array: the model port's
391
+ * `usage` deltas summed per `(provider, model)` pair, matching AG-UI's `TokenUsage` shape.
392
+ */
393
+ export interface AgUIRunUsage {
394
+ readonly provider?: string
395
+ readonly model?: string
396
+ readonly inputTokens?: number
397
+ readonly outputTokens?: number
398
+ readonly totalTokens?: number
399
+ readonly reasoningTokens?: number
400
+ readonly cachedInputTokens?: number
401
+ }
402
+
403
+ const USAGE_TOKEN_FIELDS = [
404
+ "inputTokens",
405
+ "outputTokens",
406
+ "totalTokens",
407
+ "reasoningTokens",
408
+ "cachedInputTokens",
409
+ ] as const
410
+
411
+ interface DeltaProjector {
412
+ readonly sink: AgentDeltaSink
413
+ /**
414
+ * Called per evidence item before its events go out. Terminal tool evidence adopts the open
415
+ * provisional tool call (its START/ARGS already streamed) - the returned id replaces the
416
+ * effect id so the evidence closes the same call the client is watching.
417
+ */
418
+ adopt(evidence: AgentStepEvidence): string | undefined
419
+ /**
420
+ * Close every open stream. Returns whether any text was streamed during the run, and the
421
+ * per-(provider, model) token usage sums when any `usage` delta was reported.
422
+ */
423
+ finish(): { readonly streamedText: boolean; readonly usage?: readonly AgUIRunUsage[] }
424
+ }
425
+
426
+ /**
427
+ * Project model deltas onto live AG-UI frames. Text deltas open a `TEXT_MESSAGE`, reasoning
428
+ * deltas a `REASONING` message, tool-args deltas a provisional `TOOL_CALL` - each stream closes
429
+ * when a different delta kind starts, when tool evidence lands, or at the end of the run. Deltas
430
+ * are transient observer data: none of this is persisted or replayed.
431
+ */
432
+ function createDeltaProjector(send: SseSend, turnId: string): DeltaProjector {
433
+ let counter = 0
434
+ let textId: string | undefined
435
+ let reasoningId: string | undefined
436
+ let callId: string | undefined
437
+ let streamedText = false
438
+ let usage: Map<string, UsageEntry> | undefined
439
+ const closeText = (): void => {
440
+ if (textId === undefined) return
441
+ send({ type: "TEXT_MESSAGE_END", messageId: textId, timestamp: Date.now() })
442
+ textId = undefined
443
+ }
444
+ const closeReasoning = (): void => {
445
+ if (reasoningId === undefined) return
446
+ const timestamp = Date.now()
447
+ send({ type: "REASONING_MESSAGE_END", messageId: reasoningId, timestamp })
448
+ send({ type: "REASONING_END", messageId: reasoningId, timestamp })
449
+ reasoningId = undefined
450
+ }
451
+ const closeCall = (): void => {
452
+ if (callId === undefined) return
453
+ send({ type: "TOOL_CALL_END", toolCallId: callId, timestamp: Date.now() })
454
+ callId = undefined
455
+ }
456
+ const sink: AgentDeltaSink = {
457
+ delta(delta: AgentModelDelta) {
458
+ // Usage is terminal metadata for the whole run, not a frame: sum per (provider, model)
459
+ // across model decisions without disturbing whichever stream is open.
460
+ if (delta.kind === "usage") {
461
+ usage = usage ?? new Map()
462
+ const key = `${delta.provider ?? ""}${delta.model ?? ""}`
463
+ let entry = usage.get(key)
464
+ if (entry === undefined) {
465
+ entry = {
466
+ ...(delta.provider === undefined ? {} : { provider: delta.provider }),
467
+ ...(delta.model === undefined ? {} : { model: delta.model }),
468
+ }
469
+ usage.set(key, entry)
470
+ }
471
+ for (const field of USAGE_TOKEN_FIELDS) {
472
+ const value = delta[field]
473
+ // A non-finite figure must not poison the sum; a never-reported field stays absent.
474
+ if (typeof value === "number" && Number.isFinite(value))
475
+ entry[field] = (entry[field] ?? 0) + value
476
+ }
477
+ return
478
+ }
479
+ const timestamp = Date.now()
480
+ if (delta.kind === "text") {
481
+ closeReasoning()
482
+ closeCall()
483
+ if (textId === undefined) {
484
+ textId = `${turnId}:stream:${counter}`
485
+ counter += 1
486
+ send({ type: "TEXT_MESSAGE_START", messageId: textId, role: "assistant", timestamp })
487
+ }
488
+ send({ type: "TEXT_MESSAGE_CONTENT", messageId: textId, delta: delta.text, timestamp })
489
+ streamedText = true
490
+ return
491
+ }
492
+ if (delta.kind === "reasoning") {
493
+ closeText()
494
+ closeCall()
495
+ if (reasoningId === undefined) {
496
+ reasoningId = `${turnId}:reasoning:${counter}`
497
+ counter += 1
498
+ send({ type: "REASONING_START", messageId: reasoningId, timestamp })
499
+ send({
500
+ type: "REASONING_MESSAGE_START",
501
+ messageId: reasoningId,
502
+ role: "reasoning",
503
+ timestamp,
504
+ })
505
+ }
506
+ send({
507
+ type: "REASONING_MESSAGE_CONTENT",
508
+ messageId: reasoningId,
509
+ delta: delta.text,
510
+ timestamp,
511
+ })
512
+ return
513
+ }
514
+ closeText()
515
+ closeReasoning()
516
+ if (callId === undefined) {
517
+ callId = `${turnId}:call:${counter}`
518
+ counter += 1
519
+ send({
520
+ type: "TOOL_CALL_START",
521
+ toolCallId: callId,
522
+ toolCallName: delta.name ?? "tool",
523
+ timestamp,
524
+ })
525
+ }
526
+ send({ type: "TOOL_CALL_ARGS", toolCallId: callId, delta: delta.argsText, timestamp })
527
+ },
528
+ }
529
+ return {
530
+ sink,
531
+ adopt(evidence) {
532
+ if (evidence.kind !== "tool" || evidence.outcome === "started") return undefined
533
+ closeText()
534
+ closeReasoning()
535
+ if (callId === undefined) return undefined
536
+ const adopted = callId
537
+ callId = undefined
538
+ return adopted
539
+ },
540
+ finish() {
541
+ closeCall()
542
+ closeReasoning()
543
+ closeText()
544
+ return { streamedText, ...(usage === undefined ? {} : { usage: [...usage.values()] }) }
545
+ },
546
+ }
547
+ }
548
+
549
+ /** Mutable accumulator behind one `AgUIRunUsage` entry. */
550
+ interface UsageEntry {
551
+ provider?: string
552
+ model?: string
553
+ inputTokens?: number
554
+ outputTokens?: number
555
+ totalTokens?: number
556
+ reasoningTokens?: number
557
+ cachedInputTokens?: number
558
+ }
559
+
560
+ /** JSON Schema for the payload a spec `resume` entry must carry back. */
561
+ const RESUME_RESPONSE_SCHEMA: Record<string, unknown> = {
562
+ type: "object",
563
+ required: ["continuation"],
564
+ properties: {
565
+ continuation: {
566
+ type: "object",
567
+ description: "Echo of the interrupt's metadata.continuation, with the tool input replayed.",
568
+ },
569
+ approval: {
570
+ type: "object",
571
+ properties: { granted: { type: "boolean" }, reason: { type: "string" } },
572
+ },
573
+ },
574
+ }
575
+
576
+ /** The events that close a run, derived once so live delivery and replay stay identical. */
577
+ function terminalEvents(
578
+ identity: RunIdentity,
579
+ result: AgentRunResult<unknown>,
580
+ snapshotMessages: unknown,
581
+ usage?: readonly AgUIRunUsage[],
582
+ ): readonly Record<string, unknown>[] {
583
+ const timestamp = Date.now()
584
+ // Usage rides the spec's RUN_FINISHED `usage` array, so the stored terminal events keep it
585
+ // and a replayed stream reports the same totals as the live one.
586
+ const withUsage = usage === undefined ? {} : { usage }
587
+ if (result.status === "completed") {
588
+ if (result.error !== undefined)
589
+ return [{ type: "RUN_ERROR", message: result.error.code, timestamp }]
590
+ const messageId = `${identity.turnId}:output`
591
+ const delta = typeof result.output === "string" ? result.output : JSON.stringify(result.output)
592
+ return [
593
+ { type: "TEXT_MESSAGE_START", messageId, role: "assistant", timestamp },
594
+ { type: "TEXT_MESSAGE_CONTENT", messageId, delta, timestamp },
595
+ { type: "TEXT_MESSAGE_END", messageId, timestamp },
596
+ ...messagesSnapshot(snapshotMessages, messageId, delta, timestamp),
597
+ {
598
+ type: "RUN_FINISHED",
599
+ threadId: identity.threadId,
600
+ runId: identity.runId,
601
+ result: result.output,
602
+ outcome: { type: "success" },
603
+ ...withUsage,
604
+ timestamp,
605
+ },
606
+ ]
607
+ }
608
+ if (result.status === "suspended") {
609
+ return [
610
+ {
611
+ type: "CUSTOM",
612
+ name: "nifra.pending",
613
+ value: { turnId: identity.turnId, reason: result.reason, continuation: result.pending },
614
+ timestamp,
615
+ },
616
+ {
617
+ type: "RUN_FINISHED",
618
+ threadId: identity.threadId,
619
+ runId: identity.runId,
620
+ outcome: {
621
+ type: "interrupt",
622
+ interrupts: [
623
+ {
624
+ id: identity.turnId,
625
+ reason: result.reason,
626
+ ...(result.pending.tool === undefined ? {} : { toolCallId: result.pending.effectId }),
627
+ responseSchema: RESUME_RESPONSE_SCHEMA,
628
+ metadata: { turnId: identity.turnId, continuation: result.pending },
629
+ },
630
+ ],
631
+ },
632
+ ...withUsage,
633
+ timestamp,
634
+ },
635
+ ]
636
+ }
637
+ return [
638
+ {
639
+ type: "RUN_FINISHED",
640
+ threadId: identity.threadId,
641
+ runId: identity.runId,
642
+ ...withUsage,
643
+ timestamp,
644
+ },
645
+ ]
646
+ }
647
+
648
+ /**
649
+ * The optional authoritative message list: the client-sent messages echoed back with the
650
+ * assistant output appended. Emitted only when `emitMessagesSnapshot` is on.
651
+ */
652
+ function messagesSnapshot(
653
+ messages: unknown,
654
+ messageId: string,
655
+ content: string,
656
+ timestamp: number,
657
+ ): readonly Record<string, unknown>[] {
658
+ if (messages === undefined) return []
659
+ const echoed = Array.isArray(messages) ? messages : []
660
+ return [
661
+ {
662
+ type: "MESSAGES_SNAPSHOT",
663
+ messages: [...echoed, { id: messageId, role: "assistant", content }],
664
+ timestamp,
665
+ },
666
+ ]
667
+ }
668
+
669
+ /** Parse the stored terminal value - a durable log adapter may hand back anything. */
670
+ function asTerminalEvents(value: unknown): readonly Record<string, unknown>[] | undefined {
671
+ const record = asRecord(value)
672
+ if (record === undefined || !Array.isArray(record.events)) return undefined
673
+ const events: Record<string, unknown>[] = []
674
+ for (const event of record.events) {
675
+ const parsed = asRecord(event)
676
+ if (parsed === undefined) return undefined
677
+ events.push(parsed)
678
+ }
679
+ return events
680
+ }
681
+
682
+ /** Telemetry-grade storage: a failing log must not fail the turn or mask its result. */
683
+ async function finishQuietly(
684
+ log: AgentEvidenceLog | undefined,
685
+ turnId: string,
686
+ result: unknown,
687
+ ): Promise<void> {
688
+ if (log === undefined) return
689
+ try {
690
+ await log.finish(turnId, result)
691
+ } catch {
692
+ // Replay degrades to unavailable; the in-band response is unaffected.
693
+ }
694
+ }
695
+
696
+ /** Map one step-evidence item onto AG-UI events. Evidence is token-only by design. */
697
+ function evidenceEvents(
698
+ evidence: AgentStepEvidence,
699
+ turnId: string,
700
+ openToolCallId?: string,
701
+ ): readonly Record<string, unknown>[] {
702
+ const timestamp = evidence.at
703
+ if (evidence.kind === "tool") {
704
+ // When the model streamed this call's arguments, its TOOL_CALL_START (with TOOL_CALL_ARGS)
705
+ // already went out under the projector's provisional id - close that call instead of
706
+ // opening a second one under the effect id.
707
+ const toolCallId = openToolCallId ?? evidence.effectId ?? `${evidence.kind}:${evidence.seq}`
708
+ const start = {
709
+ type: "TOOL_CALL_START",
710
+ toolCallId,
711
+ toolCallName: evidence.name ?? "tool",
712
+ timestamp,
713
+ }
714
+ // The runner records one terminal evidence item per tool effect (committed/failed/denied),
715
+ // so a single item expands to the START/END/RESULT triple AG-UI clients expect. The RESULT
716
+ // content is the token-only outcome - the runtime never carries tool payloads.
717
+ if (evidence.outcome === "started") return openToolCallId === undefined ? [start] : []
718
+ return [
719
+ ...(openToolCallId === undefined ? [start] : []),
720
+ { type: "TOOL_CALL_END", toolCallId, timestamp },
721
+ {
722
+ type: "TOOL_CALL_RESULT",
723
+ messageId: `${turnId}:tool:${evidence.seq}`,
724
+ toolCallId,
725
+ role: "tool",
726
+ content: JSON.stringify({
727
+ outcome: evidence.outcome,
728
+ ...(evidence.code === undefined ? {} : { code: evidence.code }),
729
+ }),
730
+ timestamp,
731
+ },
732
+ ]
733
+ }
734
+ const stepName = evidence.name === undefined ? evidence.kind : `${evidence.kind}:${evidence.name}`
735
+ if (evidence.outcome === "started") return [{ type: "STEP_STARTED", stepName, timestamp }]
736
+ return [{ type: "STEP_FINISHED", stepName, timestamp }]
737
+ }
738
+
739
+ function lastUserMessage(messages: unknown): unknown {
740
+ if (!Array.isArray(messages)) return undefined
741
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
742
+ const message = asRecord(messages[index])
743
+ if (message !== undefined && message.role === "user") return message.content
744
+ }
745
+ return undefined
746
+ }
747
+
748
+ function parseResume(raw: unknown): AgentTurnInput["resume"] | undefined {
749
+ const record = asRecord(raw)
750
+ if (record === undefined) return undefined
751
+ const continuation = asRecord(record.continuation)
752
+ if (continuation === undefined) return undefined
753
+ const kind = continuation.kind
754
+ if (typeof kind !== "string" || !PENDING_KINDS.includes(kind as AgentPendingKind))
755
+ return undefined
756
+ if (typeof continuation.effectId !== "string") return undefined
757
+ if (continuation.tool !== undefined && typeof continuation.tool !== "string") return undefined
758
+ const approval = asRecord(record.approval)
759
+ return {
760
+ continuation: {
761
+ kind: kind as AgentPendingKind,
762
+ ...(continuation.tool === undefined ? {} : { tool: continuation.tool as string }),
763
+ ...(Object.hasOwn(continuation, "input") ? { input: continuation.input } : {}),
764
+ effectId: continuation.effectId,
765
+ },
766
+ ...(approval !== undefined && typeof approval.granted === "boolean"
767
+ ? {
768
+ approval: {
769
+ granted: approval.granted,
770
+ ...(typeof approval.reason === "string" ? { reason: approval.reason } : {}),
771
+ },
772
+ }
773
+ : {}),
774
+ }
775
+ }
776
+
777
+ /**
778
+ * Parse the spec `RunAgentInput.resume` array. The runtime holds no interrupt registry - the
779
+ * interrupt id IS the turn id, and the entry's `payload` must echo the interrupt's
780
+ * `metadata.continuation`. A single pending suspension means a single entry; extras are ignored.
781
+ */
782
+ function parseResumeEntry(
783
+ raw: unknown,
784
+ ): { readonly turnId: string; readonly resume: AgentTurnInput["resume"] } | undefined {
785
+ if (!Array.isArray(raw) || raw.length === 0) return undefined
786
+ const entry = asRecord(raw[0])
787
+ if (entry === undefined) return undefined
788
+ const interruptId = entry.interruptId
789
+ if (typeof interruptId !== "string" || !TURN_ID_PATTERN.test(interruptId)) return undefined
790
+ const status = entry.status
791
+ if (status !== "resolved" && status !== "cancelled") return undefined
792
+ const payload = asRecord(entry.payload) ?? {}
793
+ const approval = asRecord(payload.approval)
794
+ const resume = parseResume({
795
+ continuation: payload.continuation,
796
+ // A cancelled entry without an explicit approval decision resumes as a denial.
797
+ approval: approval ?? (status === "cancelled" ? { granted: false } : undefined),
798
+ })
799
+ if (resume === undefined) return undefined
800
+ return { turnId: interruptId, resume }
801
+ }
802
+
803
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
804
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined
805
+ return value as Record<string, unknown>
806
+ }
807
+
808
+ function jsonResponse(status: number, body: unknown): Response {
809
+ return new Response(JSON.stringify(body), {
810
+ status,
811
+ headers: { "content-type": "application/json; charset=utf-8" },
812
+ })
813
+ }
814
+
815
+ function render(response: Response | ResponseResult): Response {
816
+ return response instanceof Response ? response : toResponse(response, EMPTY_RESPONSE_CONTROLS)
817
+ }