@nifrajs/agent 3.1.0 → 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 +36 -0
- package/dist/deployment.d.ts +157 -0
- package/dist/deployment.d.ts.map +1 -0
- package/dist/deployment.js +374 -0
- package/dist/deployment.js.map +1 -0
- package/dist/events.d.ts +41 -0
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +75 -0
- package/dist/events.js.map +1 -1
- package/dist/execution-policy.d.ts.map +1 -1
- package/dist/execution-policy.js +11 -7
- package/dist/execution-policy.js.map +1 -1
- package/dist/gateway.d.ts +153 -0
- package/dist/gateway.d.ts.map +1 -0
- package/dist/gateway.js +409 -0
- package/dist/gateway.js.map +1 -0
- package/dist/index.d.ts +86 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +163 -1
- package/dist/index.js.map +1 -1
- package/dist/mount.d.ts +59 -0
- package/dist/mount.d.ts.map +1 -0
- package/dist/mount.js +247 -0
- package/dist/mount.js.map +1 -0
- package/dist/registry.d.ts +112 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +326 -0
- package/dist/registry.js.map +1 -0
- package/package.json +12 -2
- package/src/deployment.ts +621 -0
- package/src/events.ts +125 -0
- package/src/execution-policy.ts +9 -5
- package/src/gateway.ts +607 -0
- package/src/index.ts +303 -1
- package/src/mount.ts +344 -0
- package/src/registry.ts +444 -0
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 {}
|