@nifrajs/agent 3.1.0 → 3.3.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 CHANGED
@@ -3,7 +3,10 @@
3
3
  *
4
4
  * One turn makes one model decision and may execute one {@link ToolContract}. The multi-turn runner
5
5
  * is intentionally bounded. The state adapter stores token-only evidence and cursor metadata; model
6
- * input, tool input, and tool output remain transient caller data.
6
+ * input, tool input, and tool output remain transient caller data. A model port may additionally
7
+ * stream progressive output (text, reasoning, tool-call arguments) through the request's optional
8
+ * `onDelta` callback when the caller wires an {@link AgentDeltaSink} into the ports - deltas are
9
+ * transient observer data for live UIs, never evidence, and are never persisted by the runtime.
7
10
  */
8
11
 
9
12
  import { canAttempt, type RequestBudget } from "@nifrajs/core/budget"
@@ -21,6 +24,40 @@ import {
21
24
  type ToolIdempotencyStore,
22
25
  } from "@nifrajs/core/tool-contract"
23
26
 
27
+ export {
28
+ AgentDeployment,
29
+ type AgentDeploymentAdapter,
30
+ type AgentDeploymentPlan,
31
+ assertDeploymentAuthorityMonotonic,
32
+ createDeploymentAuthority,
33
+ type DeploymentActivationOptions,
34
+ type DeploymentAuthority,
35
+ type DeploymentCancellation,
36
+ type DeploymentCancelRequest,
37
+ type DeploymentCancelResult,
38
+ type DeploymentCapabilities,
39
+ type DeploymentCapabilityReport,
40
+ type DeploymentDisposeRequest,
41
+ type DeploymentDisposeResult,
42
+ DeploymentError,
43
+ type DeploymentErrorCode,
44
+ type DeploymentEvidence,
45
+ type DeploymentFilesystem,
46
+ type DeploymentInspection,
47
+ type DeploymentInspectRequest,
48
+ type DeploymentNetwork,
49
+ type DeploymentPrepareRequest,
50
+ type DeploymentPrepareResult,
51
+ type DeploymentProcess,
52
+ type DeploymentRuntime,
53
+ type DeploymentSecrets,
54
+ type DeploymentStartRequest,
55
+ type DeploymentStartResult,
56
+ type DeploymentState,
57
+ type HostileCodeIsolation,
58
+ parseDeploymentCapabilityReport,
59
+ parseDeploymentPlan,
60
+ } from "./deployment.ts"
24
61
  export {
25
62
  createLocalProcessAdapter,
26
63
  type ExecutionPolicy,
@@ -32,6 +69,39 @@ export {
32
69
  type LocalProcessRequest,
33
70
  type LocalProcessResult,
34
71
  } from "./execution-policy.ts"
72
+ export {
73
+ createStructuredOutputParser,
74
+ FakeModelGateway,
75
+ type FakeModelGatewayOptions,
76
+ isModelGatewayErrorCode,
77
+ MODEL_GATEWAY_ERROR_CODES,
78
+ type ModelGateway,
79
+ type ModelGatewayAttemptRequest,
80
+ type ModelGatewayBudget,
81
+ type ModelGatewayEnvelope,
82
+ type ModelGatewayError,
83
+ type ModelGatewayErrorCode,
84
+ type ModelGatewayEvidence,
85
+ type ModelGatewayExecutionResult,
86
+ type ModelGatewayFailure,
87
+ ModelGatewayFailureError,
88
+ type ModelGatewayRawResult,
89
+ type ModelGatewayRequest,
90
+ type ModelGatewayResult,
91
+ type ModelGatewaySuccess,
92
+ type ModelGatewayTerminalFailure,
93
+ type ModelGatewayUsage,
94
+ type ModelRoute,
95
+ type ModelRoutePolicy,
96
+ type ModelRouteRef,
97
+ parseModelGatewayError,
98
+ parseModelGatewayResult,
99
+ ReplayModelGateway,
100
+ type ReplayModelGatewayOptions,
101
+ runModelGateway,
102
+ type StructuredOutputParser,
103
+ structuredOutputParser,
104
+ } from "./gateway.ts"
35
105
 
36
106
  export type AgentStatus = "continue" | "completed" | "suspended"
37
107
  export type AgentPendingKind = "approval" | "budget" | "model" | "cancelled"
@@ -91,6 +161,14 @@ export interface AgentModelRequest {
91
161
  readonly signal: AbortSignal
92
162
  /** Wall-clock budget shared with the parent request. Use withDeadlineHeader for outbound requests. */
93
163
  readonly deadline?: RequestBudget
164
+ /**
165
+ * Present when an observer asked for progressive output (see {@link AgentPorts.deltas}). A
166
+ * streaming port calls it per chunk while producing the decision; emitting is optional and
167
+ * best-effort - a port that returns only complete responses ignores it, and callback failures
168
+ * never reach the port. Deltas are transient observer data, not evidence: they are not
169
+ * validated, not persisted, and not replayed.
170
+ */
171
+ readonly onDelta?: (delta: AgentModelDelta) => void
94
172
  }
95
173
 
96
174
  export type AgentModelResponse =
@@ -102,6 +180,57 @@ export interface AgentModelPort {
102
180
  complete(request: AgentModelRequest): unknown | PromiseLike<unknown>
103
181
  }
104
182
 
183
+ /**
184
+ * One progressive chunk of a model decision in flight: user-visible text, reasoning text, or the
185
+ * raw argument text of the tool call being formed (`name` on the first chunk when the provider
186
+ * announces it). A `usage` delta reports the decision's token counts once the provider settles
187
+ * them, optionally attributed to a provider and model - observers sum across decisions. Chunks
188
+ * are provider output surface, not evidence - the runtime never stores them.
189
+ */
190
+ export type AgentModelDelta =
191
+ | { readonly kind: "text"; readonly text: string }
192
+ | { readonly kind: "reasoning"; readonly text: string }
193
+ | { readonly kind: "tool-args"; readonly name?: string; readonly argsText: string }
194
+ | {
195
+ readonly kind: "usage"
196
+ readonly provider?: string
197
+ readonly model?: string
198
+ readonly inputTokens?: number
199
+ readonly outputTokens?: number
200
+ readonly totalTokens?: number
201
+ readonly reasoningTokens?: number
202
+ readonly cachedInputTokens?: number
203
+ }
204
+
205
+ /** A transient observer of model deltas - an SSE bridge, a live console, a progress meter. */
206
+ export interface AgentDeltaSink {
207
+ delta(delta: AgentModelDelta): void
208
+ }
209
+
210
+ /**
211
+ * Fan model deltas out to several sinks - a protocol bridge and a logger can watch the same run.
212
+ * `undefined` entries are skipped, and the combined sink is `undefined` when none remain. Each
213
+ * sink is isolated: one sink throwing never starves the others.
214
+ */
215
+ export function combineAgentDeltaSinks(
216
+ ...sinks: readonly (AgentDeltaSink | undefined)[]
217
+ ): AgentDeltaSink | undefined {
218
+ const live = sinks.filter((sink): sink is AgentDeltaSink => sink !== undefined)
219
+ if (live.length === 0) return undefined
220
+ if (live.length === 1) return live[0]
221
+ return {
222
+ delta(delta) {
223
+ for (const sink of live) {
224
+ try {
225
+ sink.delta(delta)
226
+ } catch {
227
+ // Deltas are best-effort observability; a failing sink must not affect its peers.
228
+ }
229
+ }
230
+ },
231
+ }
232
+ }
233
+
105
234
  export interface AgentToolResult {
106
235
  readonly name: string
107
236
  readonly ok: boolean
@@ -156,6 +285,152 @@ export interface AgentTelemetryPort {
156
285
  step(evidence: AgentStepEvidence): void | PromiseLike<void>
157
286
  }
158
287
 
288
+ /**
289
+ * Fan one run's step evidence out to several telemetry ports - an SSE evidence stream and an
290
+ * exporter can observe the same turn. Ports are awaited in argument order; `undefined` entries are
291
+ * skipped, and the combined port is `undefined` when none remain.
292
+ */
293
+ export function combineAgentTelemetry(
294
+ ...ports: readonly (AgentTelemetryPort | undefined)[]
295
+ ): AgentTelemetryPort | undefined {
296
+ const live = ports.filter((port): port is AgentTelemetryPort => port !== undefined)
297
+ if (live.length === 0) return undefined
298
+ if (live.length === 1) return live[0]
299
+ return {
300
+ async step(evidence) {
301
+ for (const port of live) await port.step(evidence)
302
+ },
303
+ }
304
+ }
305
+
306
+ /** One RFC 6902 operation from the applied subset: `add`, `replace`, `remove`. */
307
+ export type AgentStatePatchOp =
308
+ | { readonly op: "add" | "replace"; readonly path: string; readonly value: unknown }
309
+ | { readonly op: "remove"; readonly path: string }
310
+
311
+ /**
312
+ * A shared, observable state document for one run - the state a live UI mirrors while the agent
313
+ * works. App code (a model port, a tool executor) calls `patch` with RFC 6902 operations; every
314
+ * subscriber sees the applied ops, and protocol bridges project them onto their wire (AG-UI
315
+ * `STATE_SNAPSHOT`/`STATE_DELTA`). The document is transient per-run observer data - it is not
316
+ * turn state, not evidence, and never persisted by the runtime.
317
+ */
318
+ export interface AgentSharedState<State = unknown> {
319
+ /** A defensive deep copy of the current document. */
320
+ snapshot(): State
321
+ /** Apply ops atomically - on an invalid op the whole batch is rejected with a TypeError. */
322
+ patch(ops: readonly AgentStatePatchOp[]): void
323
+ /** Observe applied patches. Returns the unsubscribe function. Listener failures are isolated. */
324
+ subscribe(listener: (ops: readonly AgentStatePatchOp[]) => void): () => void
325
+ }
326
+
327
+ export function createAgentSharedState<State>(initial: State): AgentSharedState<State> {
328
+ let document: unknown = structuredClone(initial)
329
+ const listeners = new Set<(ops: readonly AgentStatePatchOp[]) => void>()
330
+ return {
331
+ snapshot() {
332
+ return structuredClone(document) as State
333
+ },
334
+ patch(ops) {
335
+ // Validate-then-apply on a copy so a mid-batch failure never leaves a half-patched document.
336
+ let next = structuredClone(document)
337
+ for (const op of ops) next = applyStatePatchOp(next, op)
338
+ document = next
339
+ const applied = Object.freeze([...ops])
340
+ for (const listener of listeners) {
341
+ try {
342
+ listener(applied)
343
+ } catch {
344
+ // Observer failures must not affect the document or the other observers.
345
+ }
346
+ }
347
+ },
348
+ subscribe(listener) {
349
+ listeners.add(listener)
350
+ return () => {
351
+ listeners.delete(listener)
352
+ }
353
+ },
354
+ }
355
+ }
356
+
357
+ /** JSON Pointer segments that would graft onto the prototype chain are rejected outright. */
358
+ const FORBIDDEN_STATE_KEYS = new Set(["__proto__", "constructor", "prototype"])
359
+
360
+ function applyStatePatchOp(document: unknown, op: AgentStatePatchOp): unknown {
361
+ if (op === null || typeof op !== "object" || typeof op.path !== "string")
362
+ throw new TypeError("agent: invalid patch op")
363
+ if (op.op !== "add" && op.op !== "replace" && op.op !== "remove")
364
+ throw new TypeError("agent: unsupported patch op")
365
+ if (op.path === "") {
366
+ if (op.op === "remove") throw new TypeError("agent: cannot remove the document root")
367
+ return op.value
368
+ }
369
+ if (!op.path.startsWith("/")) throw new TypeError("agent: patch path must be a JSON Pointer")
370
+ const segments = op.path
371
+ .slice(1)
372
+ .split("/")
373
+ .map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"))
374
+ let parent: unknown = document
375
+ for (let index = 0; index < segments.length - 1; index += 1)
376
+ parent = stateChild(parent, segments[index] as string)
377
+ const key = segments[segments.length - 1] as string
378
+ if (Array.isArray(parent)) {
379
+ if (op.op === "add" && key === "-") {
380
+ parent.push(op.value)
381
+ return document
382
+ }
383
+ const index = asStateIndex(key, parent.length + (op.op === "add" ? 1 : 0))
384
+ if (op.op === "add") parent.splice(index, 0, op.value)
385
+ else if (op.op === "replace") parent[index] = op.value
386
+ else parent.splice(index, 1)
387
+ return document
388
+ }
389
+ const record = asStateRecord(parent)
390
+ if (FORBIDDEN_STATE_KEYS.has(key)) throw new TypeError("agent: forbidden patch path segment")
391
+ if (op.op === "remove") {
392
+ if (!Object.hasOwn(record, key)) throw new TypeError("agent: patch path does not exist")
393
+ delete record[key]
394
+ return document
395
+ }
396
+ if (op.op === "replace" && !Object.hasOwn(record, key))
397
+ throw new TypeError("agent: patch path does not exist")
398
+ record[key] = op.value
399
+ return document
400
+ }
401
+
402
+ function stateChild(parent: unknown, segment: string): unknown {
403
+ if (Array.isArray(parent)) return parent[asStateIndex(segment, parent.length)]
404
+ const record = asStateRecord(parent)
405
+ if (FORBIDDEN_STATE_KEYS.has(segment) || !Object.hasOwn(record, segment))
406
+ throw new TypeError("agent: patch path does not exist")
407
+ return record[segment]
408
+ }
409
+
410
+ function asStateIndex(segment: string, bound: number): number {
411
+ if (!/^(0|[1-9]\d{0,9})$/.test(segment)) throw new TypeError("agent: invalid array index")
412
+ const index = Number(segment)
413
+ if (index >= bound) throw new TypeError("agent: array index out of bounds")
414
+ return index
415
+ }
416
+
417
+ function asStateRecord(value: unknown): Record<string, unknown> {
418
+ if (value === null || typeof value !== "object" || Array.isArray(value))
419
+ throw new TypeError("agent: patch path does not exist")
420
+ return value as Record<string, unknown>
421
+ }
422
+
423
+ /** A sink failure must surface as neither a model failure nor a dropped run. */
424
+ function guardedDelta(sink: AgentDeltaSink): (delta: AgentModelDelta) => void {
425
+ return (delta) => {
426
+ try {
427
+ sink.delta(delta)
428
+ } catch {
429
+ // Deltas are best-effort observability.
430
+ }
431
+ }
432
+ }
433
+
159
434
  export interface AgentPorts {
160
435
  readonly model: AgentModelPort
161
436
  readonly capabilities: readonly string[]
@@ -166,6 +441,8 @@ export interface AgentPorts {
166
441
  readonly state?: AgentStateStore
167
442
  readonly approval?: AgentApprovalPort
168
443
  readonly telemetry?: AgentTelemetryPort
444
+ /** Receives model deltas when the model port streams. Transient - nothing here is persisted. */
445
+ readonly deltas?: AgentDeltaSink
169
446
  readonly clock?: () => number
170
447
  /** Adapter used to satisfy execution policies declared by tool contracts. */
171
448
  readonly executionPolicy?: ExecutionPolicyAdapter
@@ -382,6 +659,7 @@ export async function turn<
382
659
  ...(ports.deadline === undefined
383
660
  ? {}
384
661
  : { deadline: ports.deadline.child(AGENT_SETTLE_RESERVE_MS) }),
662
+ ...(ports.deltas === undefined ? {} : { onDelta: guardedDelta(ports.deltas) }),
385
663
  })
386
664
  response = parseModelResponse(rawResponse)
387
665
  } catch {
@@ -801,3 +1079,27 @@ function freezeState(state: AgentTurnState): AgentTurnState {
801
1079
  ...(state.pending === undefined ? {} : { pending: Object.freeze({ ...state.pending }) }),
802
1080
  })
803
1081
  }
1082
+
1083
+ // Unified capability descriptor registry. Re-exported at the root and via the `./registry` subpath so
1084
+ // the MCP and coding-agent adapters can consume the descriptor contracts through the one declared edge.
1085
+ export type {
1086
+ ApprovalClass,
1087
+ CapabilityDescriptor,
1088
+ CapabilityKind,
1089
+ DescriptorInput,
1090
+ IdempotencyClass,
1091
+ IsolationClass,
1092
+ RegistryErrorCode,
1093
+ RegistrySnapshot,
1094
+ RetryClass,
1095
+ ToolDescriptorOptions,
1096
+ } from "./registry.ts"
1097
+ export {
1098
+ CAPABILITY_DESCRIPTOR_VERSION,
1099
+ composeDescriptor,
1100
+ composeRegistrySnapshot,
1101
+ descriptorFromTool,
1102
+ parseCapabilityDescriptor,
1103
+ REGISTRY_SNAPSHOT_VERSION,
1104
+ RegistryError,
1105
+ } from "./registry.ts"
package/src/mount.ts ADDED
@@ -0,0 +1,344 @@
1
+ /**
2
+ * `mountAgent` - the one-call HTTP + SSE seam for a {@link AgentDefinition}.
3
+ *
4
+ * It translates HTTP to the bounded runner and back, nothing more. The request body is read through
5
+ * core's single bounded, proto-guarded framing lane (`@nifrajs/core/edge-kit`); the run itself is
6
+ * driven by {@link runAgent}/{@link resumeAgent}. The caller supplies the ports through a per-request
7
+ * factory, so the model, durable state store, and approval transport - the operated, credentialed, or
8
+ * tenant-scoped pieces - are injected here and never live in this package.
9
+ *
10
+ * Wire contract (default path `/agent`):
11
+ * - `POST {path}` body `{ input, turnId?, resume? }`:
12
+ * - `Accept: text/event-stream` -> SSE: one `event: step` per {@link AgentStepEvidence}, then a final
13
+ * `event: result` carrying the projected run result, then the stream closes.
14
+ * - otherwise -> a single JSON body: the projected run result.
15
+ * - `resume` is `{ continuation, approval? }` (see {@link AgentTurnInput}); the runtime keeps state
16
+ * token-only, so a suspended tool's input must be replayed by the caller in `continuation.input`.
17
+ * - With an `evidenceLog` configured, `step` frames carry `id: <seq>` and a dropped SSE connection
18
+ * is resumable: re-POST the same `turnId` with a `Last-Event-ID` header to replay the missed
19
+ * evidence and rejoin the still-running turn (or receive the stored terminal frame) - the run is
20
+ * never re-executed. Without the log the header is ignored and every POST starts a run.
21
+ * - The seam performs no authentication or authorization. Wrap it with the app's own route guards, and
22
+ * scope the store/model returned by `ports` to the caller (RLS born in at the data layer).
23
+ */
24
+
25
+ import {
26
+ EMPTY_RESPONSE_CONTROLS,
27
+ type ProtoPoisoning,
28
+ plainError,
29
+ type ResponseResult,
30
+ readBodyFramed,
31
+ toResponse,
32
+ } from "@nifrajs/core/edge-kit"
33
+ import type { StandardSchemaV1 } from "@nifrajs/core/schema"
34
+ import {
35
+ type AgentEvidenceLog,
36
+ type AgentEvidenceReplay,
37
+ createAgentEvidenceStream,
38
+ } from "./events.ts"
39
+ import {
40
+ type AgentDefinition,
41
+ type AgentPendingKind,
42
+ type AgentPorts,
43
+ type AgentRunResult,
44
+ type AgentTurnInput,
45
+ combineAgentTelemetry,
46
+ createAgentState,
47
+ resumeAgent,
48
+ runAgent,
49
+ } from "./index.ts"
50
+
51
+ const DEFAULT_PATH = "/agent"
52
+ const DEFAULT_MAX_BODY_BYTES = 1_000_000
53
+ const PENDING_KINDS: readonly AgentPendingKind[] = ["approval", "budget", "model", "cancelled"]
54
+ const LAST_EVENT_ID_PATTERN = /^\d{1,15}$/
55
+
56
+ /** The structural slice of a route context the seam needs. Kept loose so core stays a peer dependency. */
57
+ export interface AgentRouteContext {
58
+ readonly req: Request
59
+ }
60
+
61
+ /** The structural slice of a nifra server `mountAgent` needs. */
62
+ export interface AgentMountableApp {
63
+ post(path: string, handler: (c: AgentRouteContext) => Response | Promise<Response>): unknown
64
+ }
65
+
66
+ export interface MountAgentOptions<
67
+ InputSchema extends StandardSchemaV1,
68
+ OutputSchema extends StandardSchemaV1,
69
+ > {
70
+ readonly agent: AgentDefinition<InputSchema, OutputSchema>
71
+ /** Endpoint path. Default `/agent`. */
72
+ readonly path?: string
73
+ /** Bounded run length passed to {@link runAgent}. */
74
+ readonly maxTurns?: number
75
+ /** Maximum request body size in bytes. Default 1_000_000. */
76
+ readonly maxBodyBytes?: number
77
+ /** Prototype-poisoning policy for the framing lane. Default `"reject"`. */
78
+ readonly protoPoisoning?: ProtoPoisoning
79
+ /**
80
+ * Build the ports for one request - the model, durable state store, approval transport, capabilities,
81
+ * and budgets. Receives the route context so the caller can scope every port to the request subject.
82
+ */
83
+ readonly ports: (c: AgentRouteContext) => AgentPorts | Promise<AgentPorts>
84
+ /**
85
+ * Evidence log making the SSE stream resumable via `Last-Event-ID`. The in-memory reference
86
+ * (`createMemoryAgentEvidenceLog`) is single-process; a durable log is an adapter concern.
87
+ */
88
+ readonly evidenceLog?: AgentEvidenceLog
89
+ }
90
+
91
+ /** Mount a single agent as `POST {path}`, negotiating an SSE evidence stream on `Accept`. */
92
+ export function mountAgent<
93
+ InputSchema extends StandardSchemaV1,
94
+ OutputSchema extends StandardSchemaV1,
95
+ >(app: AgentMountableApp, options: MountAgentOptions<InputSchema, OutputSchema>): void {
96
+ const path = options.path ?? DEFAULT_PATH
97
+ const maxBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
98
+ const proto = options.protoPoisoning ?? "reject"
99
+ const runOptions = options.maxTurns === undefined ? {} : { maxTurns: options.maxTurns }
100
+
101
+ app.post(path, (c) =>
102
+ readBodyFramed<Response>(
103
+ c.req,
104
+ maxBytes,
105
+ proto,
106
+ (parsed) => execute(c, parsed, options, runOptions),
107
+ (rejection) => render(rejection),
108
+ () => render(plainError(400, "bad_request")),
109
+ ).catch(() => render(plainError(400, "bad_request"))),
110
+ )
111
+ }
112
+
113
+ async function execute<InputSchema extends StandardSchemaV1, OutputSchema extends StandardSchemaV1>(
114
+ c: AgentRouteContext,
115
+ parsed: unknown,
116
+ options: MountAgentOptions<InputSchema, OutputSchema>,
117
+ runOptions: { readonly maxTurns?: number },
118
+ ): Promise<Response> {
119
+ const body = asRecord(parsed)
120
+ if (body === undefined) return jsonResponse(400, { error: "body_must_be_object" })
121
+
122
+ const turnIdRaw = typeof body.turnId === "string" ? body.turnId : undefined
123
+ const turnId = turnIdRaw ?? crypto.randomUUID()
124
+
125
+ const resume = parseResume(body.resume)
126
+ const log = options.evidenceLog
127
+ const wantsSse = (c.req.headers.get("accept") ?? "").includes("text/event-stream")
128
+
129
+ // A reconnect replays recorded evidence and rejoins the turn; it never starts a second run.
130
+ const lastEventId = c.req.headers.get("last-event-id")
131
+ if (wantsSse && log !== undefined && lastEventId !== null) {
132
+ if (!LAST_EVENT_ID_PATTERN.test(lastEventId))
133
+ return jsonResponse(400, { error: "invalid_last_event_id" })
134
+ const replay = await log.replay(turnId, Number(lastEventId))
135
+ if (replay === undefined) return jsonResponse(409, { error: "replay_unavailable" })
136
+ return sseReplayResponse(turnId, replay)
137
+ }
138
+
139
+ const basePorts = await options.ports(c)
140
+
141
+ const start = (
142
+ telemetry?: AgentPorts["telemetry"],
143
+ ): Promise<AgentRunResult<NonNullable<OutputSchema["~standard"]["types"]>["output"]>> => {
144
+ // Compose rather than replace: an SSE evidence stream must not displace a telemetry port the
145
+ // caller injected through `ports` (an exporter, a durable evidence log).
146
+ const combined = combineAgentTelemetry(basePorts.telemetry, log?.open(turnId), telemetry)
147
+ const ports = combined === undefined ? basePorts : { ...basePorts, telemetry: combined }
148
+ if (resume !== undefined) {
149
+ return resumeAgent(options.agent, turnId, { value: body.input, resume }, ports, runOptions)
150
+ }
151
+ let state: ReturnType<typeof createAgentState>
152
+ try {
153
+ state = createAgentState(turnId)
154
+ } catch {
155
+ return Promise.reject(new BadTurnId())
156
+ }
157
+ return runAgent(options.agent, { value: body.input }, ports, { ...runOptions, state })
158
+ }
159
+
160
+ if (!wantsSse) {
161
+ try {
162
+ const result = await start()
163
+ const projected = projectResult(turnId, result)
164
+ await finishQuietly(log, turnId, { event: "result", data: projected })
165
+ return jsonResponse(200, projected)
166
+ } catch (err) {
167
+ await finishQuietly(log, turnId, { event: "error", data: { turnId, error: "run_failed" } })
168
+ if (err instanceof BadTurnId) return jsonResponse(400, { error: "invalid_turn_id" })
169
+ return jsonResponse(409, { error: "resume_unavailable" })
170
+ }
171
+ }
172
+ return sseResponse(turnId, start, log)
173
+ }
174
+
175
+ function sseResponse<Output>(
176
+ turnId: string,
177
+ start: (telemetry: AgentPorts["telemetry"]) => Promise<AgentRunResult<Output>>,
178
+ log: AgentEvidenceLog | undefined,
179
+ ): Response {
180
+ const stream = createAgentEvidenceStream()
181
+ // `id:` frames are only meaningful when a log can serve the reconnect they invite.
182
+ const withIds = log !== undefined
183
+ const body = new ReadableStream<Uint8Array>({
184
+ async start(controller) {
185
+ const send = sseSender(controller)
186
+ const run = start(stream)
187
+ // The evidence stream must terminate whether the run resolves or throws, or the `for await`
188
+ // below would hang; the run result (or the error) is still reported in-band after it drains.
189
+ run
190
+ .then(
191
+ () => {},
192
+ () => {},
193
+ )
194
+ .finally(() => stream.complete())
195
+ try {
196
+ for await (const evidence of stream)
197
+ send("step", evidence, withIds ? evidence.seq : undefined)
198
+ const frame = { event: "result", data: projectResult(turnId, await run) }
199
+ // Store the terminal frame before delivering it, so a client that misses it can replay.
200
+ await finishQuietly(log, turnId, frame)
201
+ send(frame.event, frame.data)
202
+ } catch {
203
+ const frame = { event: "error", data: { turnId, error: "run_failed" } }
204
+ await finishQuietly(log, turnId, frame)
205
+ send(frame.event, frame.data)
206
+ } finally {
207
+ controller.close()
208
+ }
209
+ },
210
+ })
211
+ return sseHeaders(body)
212
+ }
213
+
214
+ function sseReplayResponse(turnId: string, replay: AgentEvidenceReplay): Response {
215
+ const body = new ReadableStream<Uint8Array>({
216
+ async start(controller) {
217
+ const send = sseSender(controller)
218
+ try {
219
+ for (const evidence of replay.evidence) send("step", evidence, evidence.seq)
220
+ if (replay.live !== undefined)
221
+ for await (const evidence of replay.live) send("step", evidence, evidence.seq)
222
+ const frame = asTerminalFrame(await replay.result)
223
+ if (frame === undefined) send("error", { turnId, error: "run_failed" })
224
+ else send(frame.event, frame.data)
225
+ } catch {
226
+ send("error", { turnId, error: "run_failed" })
227
+ } finally {
228
+ controller.close()
229
+ }
230
+ },
231
+ })
232
+ return sseHeaders(body)
233
+ }
234
+
235
+ function sseSender(
236
+ controller: ReadableStreamDefaultController<Uint8Array>,
237
+ ): (event: string, data: unknown, id?: number) => void {
238
+ const encoder = new TextEncoder()
239
+ return (event, data, id) => {
240
+ const head = id === undefined ? "" : `id: ${id}\n`
241
+ controller.enqueue(encoder.encode(`${head}event: ${event}\ndata: ${JSON.stringify(data)}\n\n`))
242
+ }
243
+ }
244
+
245
+ function sseHeaders(body: ReadableStream<Uint8Array>): Response {
246
+ return new Response(body, {
247
+ status: 200,
248
+ headers: {
249
+ "content-type": "text/event-stream; charset=utf-8",
250
+ "cache-control": "no-cache, no-transform",
251
+ connection: "keep-alive",
252
+ },
253
+ })
254
+ }
255
+
256
+ interface TerminalFrame {
257
+ readonly event: string
258
+ readonly data: unknown
259
+ }
260
+
261
+ /** Parse the stored terminal value - a durable log adapter may hand back anything. */
262
+ function asTerminalFrame(value: unknown): TerminalFrame | undefined {
263
+ const record = asRecord(value)
264
+ if (record === undefined || typeof record.event !== "string") return undefined
265
+ return { event: record.event, data: record.data }
266
+ }
267
+
268
+ /** Telemetry-grade storage: a failing log must not fail the turn or mask its result. */
269
+ async function finishQuietly(
270
+ log: AgentEvidenceLog | undefined,
271
+ turnId: string,
272
+ frame: TerminalFrame,
273
+ ): Promise<void> {
274
+ if (log === undefined) return
275
+ try {
276
+ await log.finish(turnId, frame)
277
+ } catch {
278
+ // Replay degrades to unavailable; the in-band response is unaffected.
279
+ }
280
+ }
281
+
282
+ /** Project a run result onto the wire. State evidence is token-only by design, so it passes through. */
283
+ function projectResult<Output>(
284
+ turnId: string,
285
+ result: AgentRunResult<Output>,
286
+ ): Record<string, unknown> {
287
+ const base = { turnId, status: result.status, evidence: result.evidence }
288
+ if (result.status === "completed") {
289
+ return result.error !== undefined
290
+ ? { ...base, ok: false, error: result.error }
291
+ : { ...base, ok: true, output: result.output }
292
+ }
293
+ if (result.status === "suspended") {
294
+ return { ...base, reason: result.reason, pending: result.pending }
295
+ }
296
+ return base
297
+ }
298
+
299
+ function parseResume(raw: unknown): AgentTurnInput["resume"] | undefined {
300
+ const record = asRecord(raw)
301
+ if (record === undefined) return undefined
302
+ const continuation = asRecord(record.continuation)
303
+ if (continuation === undefined) return undefined
304
+ const kind = continuation.kind
305
+ if (typeof kind !== "string" || !PENDING_KINDS.includes(kind as AgentPendingKind))
306
+ return undefined
307
+ if (typeof continuation.effectId !== "string") return undefined
308
+ if (continuation.tool !== undefined && typeof continuation.tool !== "string") return undefined
309
+ const approval = asRecord(record.approval)
310
+ return {
311
+ continuation: {
312
+ kind: kind as AgentPendingKind,
313
+ ...(continuation.tool === undefined ? {} : { tool: continuation.tool as string }),
314
+ ...(Object.hasOwn(continuation, "input") ? { input: continuation.input } : {}),
315
+ effectId: continuation.effectId,
316
+ },
317
+ ...(approval !== undefined && typeof approval.granted === "boolean"
318
+ ? {
319
+ approval: {
320
+ granted: approval.granted,
321
+ ...(typeof approval.reason === "string" ? { reason: approval.reason } : {}),
322
+ },
323
+ }
324
+ : {}),
325
+ }
326
+ }
327
+
328
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
329
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined
330
+ return value as Record<string, unknown>
331
+ }
332
+
333
+ function jsonResponse(status: number, body: unknown): Response {
334
+ return new Response(JSON.stringify(body), {
335
+ status,
336
+ headers: { "content-type": "application/json; charset=utf-8" },
337
+ })
338
+ }
339
+
340
+ function render(response: Response | ResponseResult): Response {
341
+ return response instanceof Response ? response : toResponse(response, EMPTY_RESPONSE_CONTROLS)
342
+ }
343
+
344
+ class BadTurnId extends Error {}