@nifrajs/agent-app 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts ADDED
@@ -0,0 +1,465 @@
1
+ /**
2
+ * The Agent App client: a presentation-safe façade over an agent host.
3
+ *
4
+ * It negotiates the feature set with the host on session creation, drives turns as an ordered,
5
+ * deduplicated stream of content-free {@link AgentEventView}s, resumes a persisted log by cursor with
6
+ * gap detection, and resolves approvals and handoffs. Everything it returns upward is a view model -
7
+ * identifiers, statuses, counters, opaque references - so an untyped browser app built on this client
8
+ * can never touch prompt text, tool payloads, model output, or filesystem paths. Host-specific surfaces
9
+ * beyond the negotiated contract are reachable through the raw {@link AgentAppClient.command} escape
10
+ * hatch, which still returns a bounded {@link CommandOutcome} and never leaks the caller credential.
11
+ */
12
+
13
+ import {
14
+ type AgentSessionSnapshot,
15
+ type BoundaryCommand,
16
+ type DecisionCoordinate,
17
+ negotiateFeatures,
18
+ parseHandoffSnapshot,
19
+ parseRunSnapshot,
20
+ } from "@nifrajs/agent-protocol"
21
+ import type { AgentTransport, CommandOutcome } from "./transport.ts"
22
+ import {
23
+ type AgentEventView,
24
+ type HandoffView,
25
+ OrderedEventBuffer,
26
+ type RunView,
27
+ type SessionView,
28
+ toEventView,
29
+ toHandoffView,
30
+ toRunView,
31
+ toSessionView,
32
+ } from "./view-models.ts"
33
+
34
+ /** Interaction features the client knows how to drive. A host grants the subset it supports. */
35
+ export const AGENT_APP_FEATURES = [
36
+ "approvals",
37
+ "checkpoint",
38
+ "fork",
39
+ "handoff",
40
+ "inbox",
41
+ "reload",
42
+ "resume",
43
+ "workflows",
44
+ ] as const
45
+
46
+ export class AgentAppError extends Error {
47
+ constructor(message: string) {
48
+ super(message)
49
+ this.name = "AgentAppError"
50
+ }
51
+ }
52
+
53
+ export interface AgentAppClientOptions {
54
+ /** Features the client will request at negotiation. Defaults to {@link AGENT_APP_FEATURES}. */
55
+ readonly features?: readonly string[]
56
+ /** Bound on out-of-order live events held before a gap is skipped. Forwarded to the event buffer. */
57
+ readonly maxPending?: number
58
+ }
59
+
60
+ export interface CreateSessionInput {
61
+ readonly cwd?: string
62
+ readonly backend?: string
63
+ readonly capabilities?: readonly string[]
64
+ readonly sessionId?: string
65
+ }
66
+
67
+ export interface ResumeInput {
68
+ /** Last delivered seq. Omit or pass `-1` to replay the whole retained window. */
69
+ readonly cursor?: number
70
+ readonly limit?: number
71
+ }
72
+
73
+ /** One persisted log record reduced to ordering and a type label - never its payload. */
74
+ export interface ReplayEntryView {
75
+ readonly seq: number
76
+ readonly at: number
77
+ readonly type: string
78
+ }
79
+
80
+ export type ReplayResult =
81
+ | {
82
+ readonly status: "ok"
83
+ readonly nextCursor: number
84
+ readonly entries: readonly ReplayEntryView[]
85
+ readonly session?: SessionView
86
+ }
87
+ | {
88
+ readonly status: "resync_required"
89
+ readonly reason: string
90
+ readonly earliest: number
91
+ readonly latest: number
92
+ }
93
+
94
+ export interface PendingApprovalView {
95
+ readonly approvalId: string
96
+ readonly action: string
97
+ readonly capability: string
98
+ readonly turnId?: string
99
+ }
100
+
101
+ export interface ResolveHandoffInput {
102
+ readonly runId: string
103
+ readonly nodeId: string
104
+ readonly accept: boolean
105
+ readonly reason?: string
106
+ }
107
+
108
+ /**
109
+ * One pending decision boundary in the inbox, projected to structural coordinates and a state only.
110
+ * It never carries a prompt, a free-text reason, tool data, model output, a diagnostic, or an
111
+ * artifact - just enough identity for the UI to address the exact boundary a decision resolves.
112
+ */
113
+ export interface BoundaryItemView {
114
+ readonly kind: "approval" | "handoff"
115
+ readonly requestId: string
116
+ readonly runId: string
117
+ readonly nodeId: string
118
+ readonly capability: string
119
+ readonly vector: number
120
+ readonly expiresAt: number
121
+ readonly state: string
122
+ /** Opaque owner role once a handoff is assigned. */
123
+ readonly to?: string
124
+ }
125
+
126
+ /** The bounded outcome of a boundary decision: the resulting item, or a content-free refusal code. */
127
+ export type BoundaryDecisionResult =
128
+ | { readonly ok: true; readonly item: BoundaryItemView }
129
+ | { readonly ok: false; readonly code: string }
130
+
131
+ export class AgentAppClient {
132
+ private readonly transport: AgentTransport
133
+ private readonly requested: readonly string[]
134
+ private readonly maxPending: number | undefined
135
+ private currentSession: SessionView | undefined
136
+ private granted = new Set<string>()
137
+
138
+ constructor(transport: AgentTransport, options?: AgentAppClientOptions) {
139
+ this.transport = transport
140
+ this.requested = options?.features ?? AGENT_APP_FEATURES
141
+ this.maxPending = options?.maxPending
142
+ }
143
+
144
+ /** The current session view, or `undefined` before {@link createSession}/{@link resume}. */
145
+ get session(): SessionView | undefined {
146
+ return this.currentSession
147
+ }
148
+
149
+ /** Features the host granted at negotiation. */
150
+ get features(): readonly string[] {
151
+ return [...this.granted]
152
+ }
153
+
154
+ supports(feature: string): boolean {
155
+ return this.granted.has(feature)
156
+ }
157
+
158
+ requireFeature(feature: string): void {
159
+ if (!this.granted.has(feature))
160
+ throw new AgentAppError(`host did not grant the "${feature}" feature`)
161
+ }
162
+
163
+ /** Open a session and negotiate the feature set against the host's advertised capabilities. */
164
+ async createSession(input: CreateSessionInput = {}): Promise<SessionView> {
165
+ const outcome = await this.transport.command<unknown>({
166
+ method: "session.create",
167
+ params: input,
168
+ })
169
+ const snapshot = this.expectSnapshot(outcome, "session.create")
170
+ this.adoptSnapshot(snapshot)
171
+ return this.currentSession as SessionView
172
+ }
173
+
174
+ /**
175
+ * Stream a turn as ordered, deduplicated view models. The underlying transport receives the host's
176
+ * full event stream, but every value yielded here is content-free.
177
+ */
178
+ async *send(message: string, options?: { signal?: AbortSignal }): AsyncIterable<AgentEventView> {
179
+ if (this.currentSession === undefined)
180
+ throw new AgentAppError("createSession must be called before send")
181
+ const buffer = new OrderedEventBuffer({
182
+ from: this.currentSession.lastSeq,
183
+ ...(this.maxPending === undefined ? {} : { maxPending: this.maxPending }),
184
+ })
185
+ const stream = this.transport.stream({
186
+ method: "turn.send",
187
+ params: { message },
188
+ ...(options?.signal ? { signal: options.signal } : {}),
189
+ })
190
+ for await (const event of stream) {
191
+ for (const view of buffer.offer(toEventView(event))) yield view
192
+ }
193
+ }
194
+
195
+ /** Resume a persisted log by cursor. A cursor whose next record was evicted asks for a resync. */
196
+ async resume(input: ResumeInput = {}): Promise<ReplayResult> {
197
+ this.requireFeature("resume")
198
+ const params: Record<string, number> = { cursor: input.cursor ?? -1 }
199
+ if (input.limit !== undefined) params.limit = input.limit
200
+ const outcome = await this.transport.command<unknown>({ method: "session.events", params })
201
+ if (!outcome.ok) throw commandError("session.events", outcome)
202
+ return this.decodeResume(outcome.value)
203
+ }
204
+
205
+ /** List approvals awaiting a decision, projected to their identifiers only. */
206
+ async listApprovals(): Promise<readonly PendingApprovalView[]> {
207
+ this.requireFeature("approvals")
208
+ const outcome = await this.transport.command<unknown>({ method: "approval.list" })
209
+ if (!outcome.ok) throw commandError("approval.list", outcome)
210
+ const pending = isRecord(outcome.value) ? outcome.value.pending : undefined
211
+ if (!Array.isArray(pending)) return []
212
+ const views: PendingApprovalView[] = []
213
+ for (const item of pending) {
214
+ if (!isRecord(item)) continue
215
+ const { approvalId, action, capability, turnId } = item
216
+ if (
217
+ typeof approvalId !== "string" ||
218
+ typeof action !== "string" ||
219
+ typeof capability !== "string"
220
+ )
221
+ continue
222
+ views.push(
223
+ Object.freeze({
224
+ approvalId,
225
+ action,
226
+ capability,
227
+ ...(typeof turnId === "string" ? { turnId } : {}),
228
+ }),
229
+ )
230
+ }
231
+ return views
232
+ }
233
+
234
+ /** Approve or decline a pending approval. Returns the host's recorded decision. */
235
+ async resolveApproval(approvalId: string, approved: boolean, reason?: string): Promise<boolean> {
236
+ this.requireFeature("approvals")
237
+ const outcome = await this.transport.command<unknown>({
238
+ method: "approval.resolve",
239
+ params: { approvalId, approved, ...(reason === undefined ? {} : { reason }) },
240
+ })
241
+ if (!outcome.ok) throw commandError("approval.resolve", outcome)
242
+ return isRecord(outcome.value) && outcome.value.approved === true
243
+ }
244
+
245
+ /** Accept or decline a handoff. Returns the resulting handoff view when the host reports one. */
246
+ async resolveHandoff(input: ResolveHandoffInput): Promise<HandoffView | undefined> {
247
+ this.requireFeature("handoff")
248
+ const outcome = await this.transport.command<unknown>({
249
+ method: "handoff.resolve",
250
+ params: {
251
+ runId: input.runId,
252
+ nodeId: input.nodeId,
253
+ accept: input.accept,
254
+ ...(input.reason === undefined ? {} : { reason: input.reason }),
255
+ },
256
+ })
257
+ if (!outcome.ok) throw commandError("handoff.resolve", outcome)
258
+ if (!isRecord(outcome.value) || outcome.value.handoff === undefined) return undefined
259
+ return toHandoffView(parseHandoffSnapshot(outcome.value.handoff))
260
+ }
261
+
262
+ /**
263
+ * List every pending decision boundary (approvals and handoffs) as content-free item views. Gated
264
+ * on the negotiated `inbox` feature.
265
+ */
266
+ async listBoundaries(): Promise<readonly BoundaryItemView[]> {
267
+ this.requireFeature("inbox")
268
+ const outcome = await this.transport.command<unknown>({ method: "boundary.list" })
269
+ if (!outcome.ok) throw commandError("boundary.list", outcome)
270
+ const items = isRecord(outcome.value) ? outcome.value.items : undefined
271
+ if (!Array.isArray(items)) return []
272
+ const views: BoundaryItemView[] = []
273
+ for (const item of items) {
274
+ const view = toBoundaryItemView(item)
275
+ if (view !== undefined) views.push(view)
276
+ }
277
+ return Object.freeze(views)
278
+ }
279
+
280
+ /** Inspect one boundary by request id, projected to a content-free item view. */
281
+ async inspectBoundary(requestId: string): Promise<BoundaryItemView | undefined> {
282
+ this.requireFeature("inbox")
283
+ const outcome = await this.transport.command<unknown>({
284
+ method: "boundary.inspect",
285
+ params: { requestId },
286
+ })
287
+ if (!outcome.ok) throw commandError("boundary.inspect", outcome)
288
+ const source =
289
+ isRecord(outcome.value) && outcome.value.item !== undefined
290
+ ? outcome.value.item
291
+ : outcome.value
292
+ return toBoundaryItemView(source)
293
+ }
294
+
295
+ /**
296
+ * Drive one boundary command against its exact coordinate. Because the full {@link DecisionCoordinate}
297
+ * (run, node, capability, request id, vector, expiry) rides on every call, a decision can only ever
298
+ * resolve the boundary it names - never a different item - and the host fails a stale or mismatched
299
+ * coordinate closed. `assign` carries the owner role in `to`.
300
+ */
301
+ async decideBoundary(
302
+ command: BoundaryCommand,
303
+ coordinate: DecisionCoordinate,
304
+ options?: { readonly to?: string },
305
+ ): Promise<BoundaryDecisionResult> {
306
+ this.requireFeature("inbox")
307
+ const outcome = await this.transport.command<unknown>({
308
+ method: `boundary.${command}`,
309
+ params: { coordinate, ...(options?.to === undefined ? {} : { to: options.to }) },
310
+ })
311
+ if (!outcome.ok) throw commandError(`boundary.${command}`, outcome)
312
+ if (isRecord(outcome.value) && typeof outcome.value.rejected === "string")
313
+ return { ok: false, code: outcome.value.rejected }
314
+ const item = toBoundaryItemView(
315
+ isRecord(outcome.value) && outcome.value.item !== undefined
316
+ ? outcome.value.item
317
+ : outcome.value,
318
+ )
319
+ if (item === undefined) return { ok: false, code: "invalid_response" }
320
+ return { ok: true, item }
321
+ }
322
+
323
+ /** Fetch a run snapshot by id, projected to a content-free {@link RunView}. */
324
+ async runSnapshot(runId: string): Promise<RunView> {
325
+ const outcome = await this.transport.command<unknown>({
326
+ method: "run.snapshot",
327
+ params: { runId },
328
+ })
329
+ if (!outcome.ok) throw commandError("run.snapshot", outcome)
330
+ const value =
331
+ isRecord(outcome.value) && outcome.value.snapshot !== undefined
332
+ ? outcome.value.snapshot
333
+ : outcome.value
334
+ return toRunView(parseRunSnapshot(value))
335
+ }
336
+
337
+ /** Cancel the active turn / stop the session. */
338
+ async cancel(reason?: string): Promise<void> {
339
+ const outcome = await this.transport.command<unknown>({
340
+ method: "session.stop",
341
+ params: reason === undefined ? {} : { reason },
342
+ })
343
+ if (!outcome.ok) throw commandError("session.stop", outcome)
344
+ }
345
+
346
+ /**
347
+ * Escape hatch for host-specific commands outside the negotiated contract. Returns a bounded
348
+ * {@link CommandOutcome}; the caller is responsible for keeping whatever it renders content-free.
349
+ */
350
+ command<T = unknown>(
351
+ method: string,
352
+ params?: unknown,
353
+ signal?: AbortSignal,
354
+ ): Promise<CommandOutcome<T>> {
355
+ return this.transport.command<T>({
356
+ method,
357
+ ...(params === undefined ? {} : { params }),
358
+ ...(signal ? { signal } : {}),
359
+ })
360
+ }
361
+
362
+ private adoptSnapshot(snapshot: AgentSessionSnapshot): void {
363
+ this.currentSession = toSessionView(snapshot)
364
+ this.granted = new Set(negotiateFeatures(snapshot.capabilities, this.requested).granted)
365
+ }
366
+
367
+ private expectSnapshot(outcome: CommandOutcome<unknown>, method: string): AgentSessionSnapshot {
368
+ if (!outcome.ok) throw commandError(method, outcome)
369
+ const value = outcome.value
370
+ if (
371
+ !isRecord(value) ||
372
+ typeof value.id !== "string" ||
373
+ typeof value.backend !== "string" ||
374
+ typeof value.status !== "string" ||
375
+ typeof value.createdAt !== "number" ||
376
+ typeof value.updatedAt !== "number" ||
377
+ typeof value.lastSeq !== "number" ||
378
+ !Array.isArray(value.capabilities)
379
+ )
380
+ throw new AgentAppError(`${method} did not return a session snapshot`)
381
+ return value as unknown as AgentSessionSnapshot
382
+ }
383
+
384
+ private decodeResume(value: unknown): ReplayResult {
385
+ if (!isRecord(value)) throw new AgentAppError("session.events returned a non-object")
386
+ const resume = isRecord(value.resume) ? value.resume : value
387
+ const session =
388
+ isRecord(value.snapshot) && typeof value.snapshot.id === "string"
389
+ ? toSessionView(value.snapshot as unknown as AgentSessionSnapshot)
390
+ : undefined
391
+ if (resume.status === "resync_required") {
392
+ return Object.freeze({
393
+ status: "resync_required",
394
+ reason: typeof resume.reason === "string" ? resume.reason : "stale_cursor",
395
+ earliest: typeof resume.earliest === "number" ? resume.earliest : 0,
396
+ latest: typeof resume.latest === "number" ? resume.latest : 0,
397
+ })
398
+ }
399
+ const source = Array.isArray(resume.events)
400
+ ? resume.events
401
+ : Array.isArray(value.entries)
402
+ ? value.entries
403
+ : []
404
+ const entries: ReplayEntryView[] = []
405
+ for (const item of source) {
406
+ if (!isRecord(item)) continue
407
+ if (
408
+ typeof item.seq !== "number" ||
409
+ typeof item.at !== "number" ||
410
+ typeof item.type !== "string"
411
+ )
412
+ continue
413
+ entries.push(Object.freeze({ seq: item.seq, at: item.at, type: item.type }))
414
+ }
415
+ const tail = entries.at(-1)
416
+ const nextCursor =
417
+ typeof resume.nextCursor === "number"
418
+ ? resume.nextCursor
419
+ : typeof resume.cursor === "number"
420
+ ? resume.cursor
421
+ : (tail?.seq ?? -1)
422
+ return Object.freeze({
423
+ status: "ok",
424
+ nextCursor,
425
+ entries,
426
+ ...(session === undefined ? {} : { session }),
427
+ })
428
+ }
429
+ }
430
+
431
+ function commandError(method: string, outcome: { status: number; error: string }): AgentAppError {
432
+ return new AgentAppError(`${method} failed (${outcome.status}): ${outcome.error}`)
433
+ }
434
+
435
+ function isRecord(value: unknown): value is Record<string, unknown> {
436
+ return typeof value === "object" && value !== null && !Array.isArray(value)
437
+ }
438
+
439
+ /** Project a raw boundary record to a content-free {@link BoundaryItemView}, or drop it if malformed. */
440
+ function toBoundaryItemView(value: unknown): BoundaryItemView | undefined {
441
+ if (!isRecord(value)) return undefined
442
+ const { kind, requestId, runId, nodeId, capability, vector, expiresAt, state, to } = value
443
+ if (
444
+ (kind !== "approval" && kind !== "handoff") ||
445
+ typeof requestId !== "string" ||
446
+ typeof runId !== "string" ||
447
+ typeof nodeId !== "string" ||
448
+ typeof capability !== "string" ||
449
+ typeof vector !== "number" ||
450
+ typeof expiresAt !== "number" ||
451
+ typeof state !== "string"
452
+ )
453
+ return undefined
454
+ return Object.freeze({
455
+ kind,
456
+ requestId,
457
+ runId,
458
+ nodeId,
459
+ capability,
460
+ vector,
461
+ expiresAt,
462
+ state,
463
+ ...(typeof to === "string" ? { to } : {}),
464
+ })
465
+ }
package/src/index.ts ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * `@nifrajs/agent-app` - a presentation-safe browser SDK for Nifra agent hosts.
3
+ *
4
+ * This package depends only on `@nifrajs/agent-protocol`. It has no backend, provider, storage, model,
5
+ * or UI-framework dependency, so it can be bundled into browser-facing code without dragging a private
6
+ * engine or payload content across the boundary. Everything it surfaces upward is a content-free view
7
+ * model: identifiers, lifecycle statuses, counters, and opaque references.
8
+ */
9
+
10
+ export {
11
+ AGENT_APP_FEATURES,
12
+ AgentAppClient,
13
+ type AgentAppClientOptions,
14
+ AgentAppError,
15
+ type BoundaryDecisionResult,
16
+ type BoundaryItemView,
17
+ type CreateSessionInput,
18
+ type PendingApprovalView,
19
+ type ReplayEntryView,
20
+ type ReplayResult,
21
+ type ResolveHandoffInput,
22
+ type ResumeInput,
23
+ } from "./client.ts"
24
+ export {
25
+ type AgentTransport,
26
+ AgentTransportError,
27
+ type AgentTransportRequest,
28
+ type AuthProvider,
29
+ type CommandOutcome,
30
+ HttpAgentTransport,
31
+ type HttpAgentTransportOptions,
32
+ parseEventStream,
33
+ } from "./transport.ts"
34
+ export {
35
+ type AgentEventView,
36
+ type ApprovalRequiredView,
37
+ type ApprovalResolvedView,
38
+ type AssistantChunkView,
39
+ type BoundaryStateView,
40
+ boundaryCommands,
41
+ boundaryIsStale,
42
+ type EvalComparisonView,
43
+ type EvidenceTimelineView,
44
+ type ExtensionReloadedView,
45
+ type FaultInjectionView,
46
+ type HandoffView,
47
+ type MemoryCompactedView,
48
+ OrderedEventBuffer,
49
+ type RegistryCapabilityView,
50
+ type RepairRequiredView,
51
+ type RunStudioNodeState,
52
+ type RunStudioNodeView,
53
+ type RunStudioView,
54
+ type RunView,
55
+ type SessionFailedView,
56
+ type SessionLifecycleView,
57
+ type SessionStoppedView,
58
+ type SessionView,
59
+ type ToolCompletedView,
60
+ type ToolDeltaView,
61
+ type ToolStartedView,
62
+ type TurnStartedView,
63
+ toEvalComparisonView,
64
+ toEventView,
65
+ toEvidenceTimelineView,
66
+ toFaultInjectionViews,
67
+ toHandoffView,
68
+ toRegistryCapabilityView,
69
+ toRunStudioView,
70
+ toRunView,
71
+ toSessionView,
72
+ type VerificationCompletedView,
73
+ virtualizeEvidenceRows,
74
+ } from "./view-models.ts"