@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/README.md +135 -0
- package/dist/index.d.ts +125 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +637 -0
- package/dist/index.js.map +1 -0
- package/package.json +62 -0
- package/src/index.ts +817 -0
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
|
+
}
|