@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/dist/index.js ADDED
@@ -0,0 +1,637 @@
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
+ import { combineAgentDeltaSinks, combineAgentTelemetry, createAgentSharedState, createAgentState, resumeAgent, runAgent, } from "@nifrajs/agent";
60
+ import { createAgentEvidenceStream, } from "@nifrajs/agent/events";
61
+ import { EMPTY_RESPONSE_CONTROLS, plainError, readBodyFramed, toResponse, } from "@nifrajs/core/edge-kit";
62
+ const DEFAULT_PATH = "/agui";
63
+ const DEFAULT_MAX_BODY_BYTES = 1_000_000;
64
+ const PENDING_KINDS = ["approval", "budget", "model", "cancelled"];
65
+ const TURN_ID_PATTERN = /^[a-zA-Z0-9._:-]{1,128}$/;
66
+ const LAST_EVENT_ID_PATTERN = /^\d{1,15}$/;
67
+ /** Mount a single agent as an AG-UI `POST {path}` SSE endpoint. */
68
+ export function mountAgUI(app, options) {
69
+ const path = options.path ?? DEFAULT_PATH;
70
+ const maxBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
71
+ const proto = options.protoPoisoning ?? "reject";
72
+ const runOptions = options.maxTurns === undefined ? {} : { maxTurns: options.maxTurns };
73
+ app.post(path, (c) => readBodyFramed(c.req, maxBytes, proto, (parsed) => execute(c, parsed, options, runOptions), (rejection) => render(rejection), () => render(plainError(400, "bad_request"))).catch(() => render(plainError(400, "bad_request"))));
74
+ }
75
+ async function execute(c, parsed, options, runOptions) {
76
+ const body = asRecord(parsed);
77
+ const threadId = body === undefined ? undefined : body.threadId;
78
+ const runId = body === undefined ? undefined : body.runId;
79
+ if (body === undefined || typeof threadId !== "string" || typeof runId !== "string")
80
+ return jsonResponse(400, { error: "invalid_run_agent_input" });
81
+ const forwarded = asRecord(body.forwardedProps) ?? {};
82
+ const forwardedTurnId = typeof forwarded.turnId === "string" ? forwarded.turnId : undefined;
83
+ if (forwardedTurnId !== undefined && !TURN_ID_PATTERN.test(forwardedTurnId))
84
+ return jsonResponse(400, { error: "invalid_turn_id" });
85
+ // Legacy resume names the turn in forwardedProps; spec resume names it via the interrupt id.
86
+ const entry = parseResumeEntry(body.resume);
87
+ const turnId = forwardedTurnId ?? entry?.turnId ?? (TURN_ID_PATTERN.test(runId) ? runId : crypto.randomUUID());
88
+ const input = Object.hasOwn(forwarded, "input") ? forwarded.input : lastUserMessage(body.messages);
89
+ const resume = parseResume(forwarded.resume) ?? entry?.resume;
90
+ const log = options.evidenceLog;
91
+ const identity = { threadId, runId, turnId };
92
+ // A reconnect replays recorded evidence and rejoins the turn; it never starts a second run.
93
+ const lastEventId = c.req.headers.get("last-event-id");
94
+ if (log !== undefined && lastEventId !== null) {
95
+ if (!LAST_EVENT_ID_PATTERN.test(lastEventId))
96
+ return jsonResponse(400, { error: "invalid_last_event_id" });
97
+ const replay = await log.replay(turnId, Number(lastEventId));
98
+ if (replay === undefined)
99
+ return jsonResponse(409, { error: "replay_unavailable" });
100
+ return sseReplayResponse(identity, replay);
101
+ }
102
+ // `body.state` seeds the run's shared UI state document; patches stream as STATE_DELTA.
103
+ const sharedState = createAgentSharedState(body.state === undefined ? {} : body.state);
104
+ const basePorts = await options.ports(c, { turnId, sharedState });
105
+ const start = (telemetry, deltas) => {
106
+ // Compose rather than replace: the SSE evidence stream must not displace a telemetry port or
107
+ // delta sink the caller injected through `ports`.
108
+ const combined = combineAgentTelemetry(basePorts.telemetry, log?.open(turnId), telemetry);
109
+ const sinks = combineAgentDeltaSinks(basePorts.deltas, deltas);
110
+ const ports = {
111
+ ...basePorts,
112
+ ...(combined === undefined ? {} : { telemetry: combined }),
113
+ ...(sinks === undefined ? {} : { deltas: sinks }),
114
+ };
115
+ if (resume !== undefined) {
116
+ return resumeAgent(options.agent, turnId, { value: input, resume }, ports, runOptions);
117
+ }
118
+ return runAgent(options.agent, { value: input }, ports, {
119
+ ...runOptions,
120
+ state: createAgentState(turnId),
121
+ });
122
+ };
123
+ const snapshotMessages = options.emitMessagesSnapshot === true ? body.messages : undefined;
124
+ return sseResponse(identity, start, log, snapshotMessages, {
125
+ channel: sharedState,
126
+ announced: body.state !== undefined,
127
+ });
128
+ }
129
+ function sseResponse(identity, start, log, snapshotMessages, state) {
130
+ const stream = createAgentEvidenceStream();
131
+ // `id:` frames are only meaningful when a log can serve the reconnect they invite.
132
+ const withIds = log !== undefined;
133
+ const body = new ReadableStream({
134
+ async start(controller) {
135
+ const send = sseSender(controller);
136
+ const projector = createDeltaProjector(send, identity.turnId);
137
+ const run = start(stream, projector.sink);
138
+ // The evidence stream must terminate whether the run resolves or throws, or the `for await`
139
+ // below would hang; the run result (or the error) is still reported in-band after it drains.
140
+ run
141
+ .then(() => { }, () => { })
142
+ .finally(() => stream.complete());
143
+ send({ type: "RUN_STARTED", threadId: identity.threadId, runId: identity.runId });
144
+ send({ type: "CUSTOM", name: "nifra.turn", value: { turnId: identity.turnId } });
145
+ let announced = state.announced;
146
+ if (announced)
147
+ send({ type: "STATE_SNAPSHOT", snapshot: state.channel.snapshot(), timestamp: Date.now() });
148
+ // A first patch on an unseeded document announces the whole document instead of a delta
149
+ // against a base the client never saw.
150
+ const unsubscribe = state.channel.subscribe((ops) => {
151
+ if (!announced) {
152
+ announced = true;
153
+ send({
154
+ type: "STATE_SNAPSHOT",
155
+ snapshot: state.channel.snapshot(),
156
+ timestamp: Date.now(),
157
+ });
158
+ return;
159
+ }
160
+ send({ type: "STATE_DELTA", delta: ops, timestamp: Date.now() });
161
+ });
162
+ try {
163
+ for await (const evidence of stream) {
164
+ const openToolCallId = projector.adopt(evidence);
165
+ sendEvidence(send, evidence, identity.turnId, withIds, openToolCallId);
166
+ }
167
+ const { streamedText, usage } = projector.finish();
168
+ const events = terminalEvents(identity, await run, snapshotMessages, usage);
169
+ // Store the terminal events before delivering them, so a client that misses them can
170
+ // replay. The stored form is always the unsuppressed one - a replayed stream saw no
171
+ // deltas, so it needs the full terminal text block.
172
+ await finishQuietly(log, identity.turnId, { events });
173
+ for (const event of events) {
174
+ // Streamed text IS the assistant message; re-sending it as a terminal block would
175
+ // duplicate it on the live client.
176
+ if (streamedText && String(event.type).startsWith("TEXT_MESSAGE_"))
177
+ continue;
178
+ send(event);
179
+ }
180
+ }
181
+ catch {
182
+ projector.finish();
183
+ const events = [{ type: "RUN_ERROR", message: "run_failed", timestamp: Date.now() }];
184
+ await finishQuietly(log, identity.turnId, { events });
185
+ for (const event of events)
186
+ send(event);
187
+ }
188
+ finally {
189
+ unsubscribe();
190
+ controller.close();
191
+ }
192
+ },
193
+ });
194
+ return sseHeaders(body);
195
+ }
196
+ function sseReplayResponse(identity, replay) {
197
+ const body = new ReadableStream({
198
+ async start(controller) {
199
+ const send = sseSender(controller);
200
+ send({ type: "RUN_STARTED", threadId: identity.threadId, runId: identity.runId });
201
+ send({ type: "CUSTOM", name: "nifra.turn", value: { turnId: identity.turnId } });
202
+ try {
203
+ for (const evidence of replay.evidence)
204
+ sendEvidence(send, evidence, identity.turnId, true);
205
+ if (replay.live !== undefined)
206
+ for await (const evidence of replay.live)
207
+ sendEvidence(send, evidence, identity.turnId, true);
208
+ const events = asTerminalEvents(await replay.result);
209
+ if (events === undefined)
210
+ send({ type: "RUN_ERROR", message: "run_failed" });
211
+ else
212
+ for (const event of events)
213
+ send(event);
214
+ }
215
+ catch {
216
+ send({ type: "RUN_ERROR", message: "run_failed" });
217
+ }
218
+ finally {
219
+ controller.close();
220
+ }
221
+ },
222
+ });
223
+ return sseHeaders(body);
224
+ }
225
+ function sseSender(controller) {
226
+ const encoder = new TextEncoder();
227
+ return (event, id) => {
228
+ const head = id === undefined ? "" : `id: ${id}\n`;
229
+ controller.enqueue(encoder.encode(`${head}data: ${JSON.stringify(event)}\n\n`));
230
+ };
231
+ }
232
+ /**
233
+ * Emit one evidence item's events, stamping the `seq` only on the item's LAST frame: SSE
234
+ * `Last-Event-ID` points at the last frame received, so a partially delivered multi-event item
235
+ * (a TOOL_CALL_START without its END) is replayed whole on reconnect instead of losing its tail.
236
+ */
237
+ function sendEvidence(send, evidence, turnId, withIds, openToolCallId) {
238
+ const events = evidenceEvents(evidence, turnId, openToolCallId);
239
+ for (let index = 0; index < events.length; index += 1) {
240
+ const event = events[index];
241
+ if (withIds && index === events.length - 1)
242
+ send(event, evidence.seq);
243
+ else
244
+ send(event);
245
+ }
246
+ }
247
+ function sseHeaders(body) {
248
+ return new Response(body, {
249
+ status: 200,
250
+ headers: {
251
+ "content-type": "text/event-stream; charset=utf-8",
252
+ "cache-control": "no-cache, no-transform",
253
+ connection: "keep-alive",
254
+ },
255
+ });
256
+ }
257
+ const USAGE_TOKEN_FIELDS = [
258
+ "inputTokens",
259
+ "outputTokens",
260
+ "totalTokens",
261
+ "reasoningTokens",
262
+ "cachedInputTokens",
263
+ ];
264
+ /**
265
+ * Project model deltas onto live AG-UI frames. Text deltas open a `TEXT_MESSAGE`, reasoning
266
+ * deltas a `REASONING` message, tool-args deltas a provisional `TOOL_CALL` - each stream closes
267
+ * when a different delta kind starts, when tool evidence lands, or at the end of the run. Deltas
268
+ * are transient observer data: none of this is persisted or replayed.
269
+ */
270
+ function createDeltaProjector(send, turnId) {
271
+ let counter = 0;
272
+ let textId;
273
+ let reasoningId;
274
+ let callId;
275
+ let streamedText = false;
276
+ let usage;
277
+ const closeText = () => {
278
+ if (textId === undefined)
279
+ return;
280
+ send({ type: "TEXT_MESSAGE_END", messageId: textId, timestamp: Date.now() });
281
+ textId = undefined;
282
+ };
283
+ const closeReasoning = () => {
284
+ if (reasoningId === undefined)
285
+ return;
286
+ const timestamp = Date.now();
287
+ send({ type: "REASONING_MESSAGE_END", messageId: reasoningId, timestamp });
288
+ send({ type: "REASONING_END", messageId: reasoningId, timestamp });
289
+ reasoningId = undefined;
290
+ };
291
+ const closeCall = () => {
292
+ if (callId === undefined)
293
+ return;
294
+ send({ type: "TOOL_CALL_END", toolCallId: callId, timestamp: Date.now() });
295
+ callId = undefined;
296
+ };
297
+ const sink = {
298
+ delta(delta) {
299
+ // Usage is terminal metadata for the whole run, not a frame: sum per (provider, model)
300
+ // across model decisions without disturbing whichever stream is open.
301
+ if (delta.kind === "usage") {
302
+ usage = usage ?? new Map();
303
+ const key = `${delta.provider ?? ""}${delta.model ?? ""}`;
304
+ let entry = usage.get(key);
305
+ if (entry === undefined) {
306
+ entry = {
307
+ ...(delta.provider === undefined ? {} : { provider: delta.provider }),
308
+ ...(delta.model === undefined ? {} : { model: delta.model }),
309
+ };
310
+ usage.set(key, entry);
311
+ }
312
+ for (const field of USAGE_TOKEN_FIELDS) {
313
+ const value = delta[field];
314
+ // A non-finite figure must not poison the sum; a never-reported field stays absent.
315
+ if (typeof value === "number" && Number.isFinite(value))
316
+ entry[field] = (entry[field] ?? 0) + value;
317
+ }
318
+ return;
319
+ }
320
+ const timestamp = Date.now();
321
+ if (delta.kind === "text") {
322
+ closeReasoning();
323
+ closeCall();
324
+ if (textId === undefined) {
325
+ textId = `${turnId}:stream:${counter}`;
326
+ counter += 1;
327
+ send({ type: "TEXT_MESSAGE_START", messageId: textId, role: "assistant", timestamp });
328
+ }
329
+ send({ type: "TEXT_MESSAGE_CONTENT", messageId: textId, delta: delta.text, timestamp });
330
+ streamedText = true;
331
+ return;
332
+ }
333
+ if (delta.kind === "reasoning") {
334
+ closeText();
335
+ closeCall();
336
+ if (reasoningId === undefined) {
337
+ reasoningId = `${turnId}:reasoning:${counter}`;
338
+ counter += 1;
339
+ send({ type: "REASONING_START", messageId: reasoningId, timestamp });
340
+ send({
341
+ type: "REASONING_MESSAGE_START",
342
+ messageId: reasoningId,
343
+ role: "reasoning",
344
+ timestamp,
345
+ });
346
+ }
347
+ send({
348
+ type: "REASONING_MESSAGE_CONTENT",
349
+ messageId: reasoningId,
350
+ delta: delta.text,
351
+ timestamp,
352
+ });
353
+ return;
354
+ }
355
+ closeText();
356
+ closeReasoning();
357
+ if (callId === undefined) {
358
+ callId = `${turnId}:call:${counter}`;
359
+ counter += 1;
360
+ send({
361
+ type: "TOOL_CALL_START",
362
+ toolCallId: callId,
363
+ toolCallName: delta.name ?? "tool",
364
+ timestamp,
365
+ });
366
+ }
367
+ send({ type: "TOOL_CALL_ARGS", toolCallId: callId, delta: delta.argsText, timestamp });
368
+ },
369
+ };
370
+ return {
371
+ sink,
372
+ adopt(evidence) {
373
+ if (evidence.kind !== "tool" || evidence.outcome === "started")
374
+ return undefined;
375
+ closeText();
376
+ closeReasoning();
377
+ if (callId === undefined)
378
+ return undefined;
379
+ const adopted = callId;
380
+ callId = undefined;
381
+ return adopted;
382
+ },
383
+ finish() {
384
+ closeCall();
385
+ closeReasoning();
386
+ closeText();
387
+ return { streamedText, ...(usage === undefined ? {} : { usage: [...usage.values()] }) };
388
+ },
389
+ };
390
+ }
391
+ /** JSON Schema for the payload a spec `resume` entry must carry back. */
392
+ const RESUME_RESPONSE_SCHEMA = {
393
+ type: "object",
394
+ required: ["continuation"],
395
+ properties: {
396
+ continuation: {
397
+ type: "object",
398
+ description: "Echo of the interrupt's metadata.continuation, with the tool input replayed.",
399
+ },
400
+ approval: {
401
+ type: "object",
402
+ properties: { granted: { type: "boolean" }, reason: { type: "string" } },
403
+ },
404
+ },
405
+ };
406
+ /** The events that close a run, derived once so live delivery and replay stay identical. */
407
+ function terminalEvents(identity, result, snapshotMessages, usage) {
408
+ const timestamp = Date.now();
409
+ // Usage rides the spec's RUN_FINISHED `usage` array, so the stored terminal events keep it
410
+ // and a replayed stream reports the same totals as the live one.
411
+ const withUsage = usage === undefined ? {} : { usage };
412
+ if (result.status === "completed") {
413
+ if (result.error !== undefined)
414
+ return [{ type: "RUN_ERROR", message: result.error.code, timestamp }];
415
+ const messageId = `${identity.turnId}:output`;
416
+ const delta = typeof result.output === "string" ? result.output : JSON.stringify(result.output);
417
+ return [
418
+ { type: "TEXT_MESSAGE_START", messageId, role: "assistant", timestamp },
419
+ { type: "TEXT_MESSAGE_CONTENT", messageId, delta, timestamp },
420
+ { type: "TEXT_MESSAGE_END", messageId, timestamp },
421
+ ...messagesSnapshot(snapshotMessages, messageId, delta, timestamp),
422
+ {
423
+ type: "RUN_FINISHED",
424
+ threadId: identity.threadId,
425
+ runId: identity.runId,
426
+ result: result.output,
427
+ outcome: { type: "success" },
428
+ ...withUsage,
429
+ timestamp,
430
+ },
431
+ ];
432
+ }
433
+ if (result.status === "suspended") {
434
+ return [
435
+ {
436
+ type: "CUSTOM",
437
+ name: "nifra.pending",
438
+ value: { turnId: identity.turnId, reason: result.reason, continuation: result.pending },
439
+ timestamp,
440
+ },
441
+ {
442
+ type: "RUN_FINISHED",
443
+ threadId: identity.threadId,
444
+ runId: identity.runId,
445
+ outcome: {
446
+ type: "interrupt",
447
+ interrupts: [
448
+ {
449
+ id: identity.turnId,
450
+ reason: result.reason,
451
+ ...(result.pending.tool === undefined ? {} : { toolCallId: result.pending.effectId }),
452
+ responseSchema: RESUME_RESPONSE_SCHEMA,
453
+ metadata: { turnId: identity.turnId, continuation: result.pending },
454
+ },
455
+ ],
456
+ },
457
+ ...withUsage,
458
+ timestamp,
459
+ },
460
+ ];
461
+ }
462
+ return [
463
+ {
464
+ type: "RUN_FINISHED",
465
+ threadId: identity.threadId,
466
+ runId: identity.runId,
467
+ ...withUsage,
468
+ timestamp,
469
+ },
470
+ ];
471
+ }
472
+ /**
473
+ * The optional authoritative message list: the client-sent messages echoed back with the
474
+ * assistant output appended. Emitted only when `emitMessagesSnapshot` is on.
475
+ */
476
+ function messagesSnapshot(messages, messageId, content, timestamp) {
477
+ if (messages === undefined)
478
+ return [];
479
+ const echoed = Array.isArray(messages) ? messages : [];
480
+ return [
481
+ {
482
+ type: "MESSAGES_SNAPSHOT",
483
+ messages: [...echoed, { id: messageId, role: "assistant", content }],
484
+ timestamp,
485
+ },
486
+ ];
487
+ }
488
+ /** Parse the stored terminal value - a durable log adapter may hand back anything. */
489
+ function asTerminalEvents(value) {
490
+ const record = asRecord(value);
491
+ if (record === undefined || !Array.isArray(record.events))
492
+ return undefined;
493
+ const events = [];
494
+ for (const event of record.events) {
495
+ const parsed = asRecord(event);
496
+ if (parsed === undefined)
497
+ return undefined;
498
+ events.push(parsed);
499
+ }
500
+ return events;
501
+ }
502
+ /** Telemetry-grade storage: a failing log must not fail the turn or mask its result. */
503
+ async function finishQuietly(log, turnId, result) {
504
+ if (log === undefined)
505
+ return;
506
+ try {
507
+ await log.finish(turnId, result);
508
+ }
509
+ catch {
510
+ // Replay degrades to unavailable; the in-band response is unaffected.
511
+ }
512
+ }
513
+ /** Map one step-evidence item onto AG-UI events. Evidence is token-only by design. */
514
+ function evidenceEvents(evidence, turnId, openToolCallId) {
515
+ const timestamp = evidence.at;
516
+ if (evidence.kind === "tool") {
517
+ // When the model streamed this call's arguments, its TOOL_CALL_START (with TOOL_CALL_ARGS)
518
+ // already went out under the projector's provisional id - close that call instead of
519
+ // opening a second one under the effect id.
520
+ const toolCallId = openToolCallId ?? evidence.effectId ?? `${evidence.kind}:${evidence.seq}`;
521
+ const start = {
522
+ type: "TOOL_CALL_START",
523
+ toolCallId,
524
+ toolCallName: evidence.name ?? "tool",
525
+ timestamp,
526
+ };
527
+ // The runner records one terminal evidence item per tool effect (committed/failed/denied),
528
+ // so a single item expands to the START/END/RESULT triple AG-UI clients expect. The RESULT
529
+ // content is the token-only outcome - the runtime never carries tool payloads.
530
+ if (evidence.outcome === "started")
531
+ return openToolCallId === undefined ? [start] : [];
532
+ return [
533
+ ...(openToolCallId === undefined ? [start] : []),
534
+ { type: "TOOL_CALL_END", toolCallId, timestamp },
535
+ {
536
+ type: "TOOL_CALL_RESULT",
537
+ messageId: `${turnId}:tool:${evidence.seq}`,
538
+ toolCallId,
539
+ role: "tool",
540
+ content: JSON.stringify({
541
+ outcome: evidence.outcome,
542
+ ...(evidence.code === undefined ? {} : { code: evidence.code }),
543
+ }),
544
+ timestamp,
545
+ },
546
+ ];
547
+ }
548
+ const stepName = evidence.name === undefined ? evidence.kind : `${evidence.kind}:${evidence.name}`;
549
+ if (evidence.outcome === "started")
550
+ return [{ type: "STEP_STARTED", stepName, timestamp }];
551
+ return [{ type: "STEP_FINISHED", stepName, timestamp }];
552
+ }
553
+ function lastUserMessage(messages) {
554
+ if (!Array.isArray(messages))
555
+ return undefined;
556
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
557
+ const message = asRecord(messages[index]);
558
+ if (message !== undefined && message.role === "user")
559
+ return message.content;
560
+ }
561
+ return undefined;
562
+ }
563
+ function parseResume(raw) {
564
+ const record = asRecord(raw);
565
+ if (record === undefined)
566
+ return undefined;
567
+ const continuation = asRecord(record.continuation);
568
+ if (continuation === undefined)
569
+ return undefined;
570
+ const kind = continuation.kind;
571
+ if (typeof kind !== "string" || !PENDING_KINDS.includes(kind))
572
+ return undefined;
573
+ if (typeof continuation.effectId !== "string")
574
+ return undefined;
575
+ if (continuation.tool !== undefined && typeof continuation.tool !== "string")
576
+ return undefined;
577
+ const approval = asRecord(record.approval);
578
+ return {
579
+ continuation: {
580
+ kind: kind,
581
+ ...(continuation.tool === undefined ? {} : { tool: continuation.tool }),
582
+ ...(Object.hasOwn(continuation, "input") ? { input: continuation.input } : {}),
583
+ effectId: continuation.effectId,
584
+ },
585
+ ...(approval !== undefined && typeof approval.granted === "boolean"
586
+ ? {
587
+ approval: {
588
+ granted: approval.granted,
589
+ ...(typeof approval.reason === "string" ? { reason: approval.reason } : {}),
590
+ },
591
+ }
592
+ : {}),
593
+ };
594
+ }
595
+ /**
596
+ * Parse the spec `RunAgentInput.resume` array. The runtime holds no interrupt registry - the
597
+ * interrupt id IS the turn id, and the entry's `payload` must echo the interrupt's
598
+ * `metadata.continuation`. A single pending suspension means a single entry; extras are ignored.
599
+ */
600
+ function parseResumeEntry(raw) {
601
+ if (!Array.isArray(raw) || raw.length === 0)
602
+ return undefined;
603
+ const entry = asRecord(raw[0]);
604
+ if (entry === undefined)
605
+ return undefined;
606
+ const interruptId = entry.interruptId;
607
+ if (typeof interruptId !== "string" || !TURN_ID_PATTERN.test(interruptId))
608
+ return undefined;
609
+ const status = entry.status;
610
+ if (status !== "resolved" && status !== "cancelled")
611
+ return undefined;
612
+ const payload = asRecord(entry.payload) ?? {};
613
+ const approval = asRecord(payload.approval);
614
+ const resume = parseResume({
615
+ continuation: payload.continuation,
616
+ // A cancelled entry without an explicit approval decision resumes as a denial.
617
+ approval: approval ?? (status === "cancelled" ? { granted: false } : undefined),
618
+ });
619
+ if (resume === undefined)
620
+ return undefined;
621
+ return { turnId: interruptId, resume };
622
+ }
623
+ function asRecord(value) {
624
+ if (value === null || typeof value !== "object" || Array.isArray(value))
625
+ return undefined;
626
+ return value;
627
+ }
628
+ function jsonResponse(status, body) {
629
+ return new Response(JSON.stringify(body), {
630
+ status,
631
+ headers: { "content-type": "application/json; charset=utf-8" },
632
+ });
633
+ }
634
+ function render(response) {
635
+ return response instanceof Response ? response : toResponse(response, EMPTY_RESPONSE_CONTROLS);
636
+ }
637
+ //# sourceMappingURL=index.js.map