@nifrajs/agent-protocol 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.
@@ -0,0 +1,516 @@
1
+ /**
2
+ * Additive run-lifecycle contracts: content-free run snapshots, evidence-event envelopes, handoff
3
+ * snapshots, feature negotiation, and cursor resume. These layer on top of the orchestration value
4
+ * contracts without changing `AGENT_PROTOCOL_VERSION`, and - like the evidence contract - carry no
5
+ * payload content.
6
+ *
7
+ * Two decoder disciplines live here on purpose:
8
+ * - Transport decoders ({@link parseRunSnapshot}, {@link parseHandoffSnapshot},
9
+ * {@link parseRunPlanRef}, and the envelope of {@link parseRunEvidenceEvent}) are lenient about
10
+ * unknown additive fields so a newer host can add optional metadata without breaking an older
11
+ * client. They still reject any {@link FORBIDDEN_CONTENT_KEYS} so a payload can never ride along.
12
+ * - The evidence sink stays strict: the inner record of a {@link RunEvidenceEvent} is validated by
13
+ * {@link parseRunEvidence}, which accepts only the declared content-free keys.
14
+ */
15
+
16
+ import {
17
+ FORBIDDEN_CONTENT_KEYS,
18
+ parseRunEvidence,
19
+ RunContractError,
20
+ type RunEvidence,
21
+ } from "./orchestration.ts"
22
+
23
+ /** Run-lifecycle contract version. Additive to the session `AGENT_PROTOCOL_VERSION`. */
24
+ export const RUN_LIFECYCLE_VERSION = 1 as const
25
+
26
+ /** Terminal-or-transient state of a run as observed through evidence. */
27
+ export type RunLifecycleState =
28
+ | "submitted"
29
+ | "running"
30
+ | "paused"
31
+ | "succeeded"
32
+ | "failed"
33
+ | "cancelled"
34
+
35
+ const RUN_LIFECYCLE_STATES: readonly RunLifecycleState[] = [
36
+ "submitted",
37
+ "running",
38
+ "paused",
39
+ "succeeded",
40
+ "failed",
41
+ "cancelled",
42
+ ]
43
+
44
+ /** Handoff resolution state, surfaced content-free. */
45
+ export type HandoffStatus = "pending" | "accepted" | "declined"
46
+
47
+ const HANDOFF_STATUSES: readonly HandoffStatus[] = ["pending", "accepted", "declined"]
48
+
49
+ // ── Value contracts ───────────────────────────────────────────────────────────────────────────
50
+
51
+ /** A content-free pointer to the plan a run is executing. */
52
+ export interface RunPlanRef {
53
+ readonly id: string
54
+ /** SHA-256 hex of the canonical plan. Content-free. */
55
+ readonly digest: string
56
+ readonly nodeCount: number
57
+ }
58
+
59
+ /** Running tallies over a run's evidence stream. */
60
+ export interface RunCounters {
61
+ readonly total: number
62
+ readonly completed: number
63
+ readonly failed: number
64
+ }
65
+
66
+ /**
67
+ * The bounded current view of a run: its plan pointer, lifecycle state, resume cursor, and counters.
68
+ * `cursor` is the highest evidence `seq` reflected here; a client resumes with everything after it.
69
+ */
70
+ export interface RunSnapshot {
71
+ readonly version: typeof RUN_LIFECYCLE_VERSION
72
+ readonly runId: string
73
+ readonly plan: RunPlanRef
74
+ readonly state: RunLifecycleState
75
+ /** Highest evidence seq reflected in this snapshot, or -1 before any evidence. */
76
+ readonly cursor: number
77
+ readonly counters: RunCounters
78
+ readonly updatedAt: number
79
+ readonly failureCode?: string
80
+ }
81
+
82
+ /** A content-free pointer to one evidence record, with the stable dedupe identity. */
83
+ export interface EvidenceRef {
84
+ readonly runId: string
85
+ readonly seq: number
86
+ /** Stable per-record identity: `${runId}:${seq}`. */
87
+ readonly eventId: string
88
+ }
89
+
90
+ /** A transport envelope wrapping one strict evidence record with its stable dedupe identity. */
91
+ export interface RunEvidenceEvent {
92
+ readonly version: typeof RUN_LIFECYCLE_VERSION
93
+ /** Stable per-record identity: `${runId}:${seq}` of the inner evidence. */
94
+ readonly eventId: string
95
+ readonly evidence: RunEvidence
96
+ }
97
+
98
+ /** A content-free snapshot of one handoff between roles/agents within a run. */
99
+ export interface HandoffSnapshot {
100
+ readonly version: typeof RUN_LIFECYCLE_VERSION
101
+ readonly runId: string
102
+ readonly nodeId: string
103
+ readonly seq: number
104
+ /** Caller-opaque role identifiers. Non-secret structural coordinates. */
105
+ readonly from: string
106
+ readonly to: string
107
+ readonly status: HandoffStatus
108
+ readonly reason?: string
109
+ }
110
+
111
+ /** The stable dedupe identity for an evidence record. */
112
+ export function evidenceEventId(runId: string, seq: number): string {
113
+ return `${runId}:${seq}`
114
+ }
115
+
116
+ // ── Feature negotiation ───────────────────────────────────────────────────────────────────────
117
+
118
+ /** The result of reconciling a client's requested features against a host's offered set. */
119
+ export interface FeatureNegotiation {
120
+ /** Requested features the host offers, sorted and de-duplicated. */
121
+ readonly granted: readonly string[]
122
+ /** Requested features the host does not offer, sorted and de-duplicated. */
123
+ readonly unsupported: readonly string[]
124
+ }
125
+
126
+ /**
127
+ * Reconcile requested features against the offered set. A requested feature the host does not offer
128
+ * is reported as unsupported rather than silently dropped, so a client can degrade deliberately.
129
+ */
130
+ export function negotiateFeatures(
131
+ offered: readonly string[],
132
+ requested: readonly string[],
133
+ ): FeatureNegotiation {
134
+ const offeredSet = new Set(offered)
135
+ const granted = new Set<string>()
136
+ const unsupported = new Set<string>()
137
+ for (const feature of requested) {
138
+ if (offeredSet.has(feature)) granted.add(feature)
139
+ else unsupported.add(feature)
140
+ }
141
+ return {
142
+ granted: Object.freeze([...granted].sort()),
143
+ unsupported: Object.freeze([...unsupported].sort()),
144
+ }
145
+ }
146
+
147
+ // ── Cursor resume ─────────────────────────────────────────────────────────────────────────────
148
+
149
+ export type CursorResyncReason = "stale_cursor"
150
+
151
+ /** Events after the cursor were retained; deliver them and advance. */
152
+ export interface CursorResumeOk<T> {
153
+ readonly status: "ok"
154
+ readonly events: readonly T[]
155
+ /** Highest seq now delivered, or the input cursor when nothing followed it. */
156
+ readonly nextCursor: number
157
+ }
158
+
159
+ /** The bounded window no longer contains the record after the cursor; a full resync is required. */
160
+ export interface CursorResyncRequired {
161
+ readonly status: "resync_required"
162
+ readonly reason: CursorResyncReason
163
+ readonly earliest: number
164
+ readonly latest: number
165
+ }
166
+
167
+ export type CursorResume<T> = CursorResumeOk<T> | CursorResyncRequired
168
+
169
+ /** Sentinel cursor meaning "before any record". A fresh subscription passes this or `undefined`. */
170
+ export const CURSOR_BEFORE_ALL = -1
171
+
172
+ /**
173
+ * Resume an ordered, seq-keyed window from a cursor.
174
+ *
175
+ * The window must be sorted by ascending, strictly increasing `seq`. `cursor` is the last seq the
176
+ * client already holds; `undefined` (or {@link CURSOR_BEFORE_ALL}) means the client holds nothing.
177
+ * If the record immediately after the cursor was already evicted from the bounded window, a gap
178
+ * exists and this returns `resync_required` with reason `stale_cursor` rather than silently skipping.
179
+ */
180
+ export function resumeFromCursor<T extends { readonly seq: number }>(
181
+ window: readonly T[],
182
+ cursor?: number,
183
+ ): CursorResume<T> {
184
+ let previous = Number.NEGATIVE_INFINITY
185
+ for (const entry of window) {
186
+ if (!Number.isSafeInteger(entry.seq) || entry.seq < 0)
187
+ throw new RunContractError("cursor window seq must be a non-negative integer")
188
+ if (entry.seq <= previous)
189
+ throw new RunContractError("cursor window must be sorted by strictly increasing seq")
190
+ previous = entry.seq
191
+ }
192
+
193
+ const from = cursor === undefined ? CURSOR_BEFORE_ALL : cursor
194
+ if (!Number.isSafeInteger(from) || from < CURSOR_BEFORE_ALL)
195
+ throw new RunContractError("cursor must be a safe integer >= -1")
196
+
197
+ if (window.length === 0)
198
+ return {
199
+ status: "ok",
200
+ events: Object.freeze([]),
201
+ nextCursor: Math.max(from, CURSOR_BEFORE_ALL),
202
+ }
203
+
204
+ const earliest = (window[0] as T).seq
205
+ const latest = (window[window.length - 1] as T).seq
206
+
207
+ // A real cursor whose next record (from + 1) predates our window lost a gap to eviction. A fresh
208
+ // subscriber (CURSOR_BEFORE_ALL) has no continuity expectation and always takes the whole window.
209
+ if (from >= 0 && from + 1 < earliest)
210
+ return { status: "resync_required", reason: "stale_cursor", earliest, latest }
211
+
212
+ const events = window.filter((entry) => entry.seq > from)
213
+ const nextCursor = events.length > 0 ? (events[events.length - 1] as T).seq : from
214
+ return { status: "ok", events: Object.freeze(events), nextCursor }
215
+ }
216
+
217
+ // ── Decoders ──────────────────────────────────────────────────────────────────────────────────
218
+
219
+ const HEX64 = /^[0-9a-f]{64}$/
220
+
221
+ function isRecord(value: unknown): value is Record<string, unknown> {
222
+ return typeof value === "object" && value !== null && !Array.isArray(value)
223
+ }
224
+
225
+ function assertNoForbiddenKeys(record: Record<string, unknown>, where: string): void {
226
+ for (const key of FORBIDDEN_CONTENT_KEYS) {
227
+ if (key in record) throw new RunContractError(`${where} carries forbidden content key '${key}'`)
228
+ }
229
+ }
230
+
231
+ function str(record: Record<string, unknown>, key: string, where: string): string {
232
+ const value = record[key]
233
+ if (typeof value !== "string" || value.length === 0)
234
+ throw new RunContractError(`${where}.${key} must be a non-empty string`)
235
+ return value
236
+ }
237
+
238
+ function nonNegInt(record: Record<string, unknown>, key: string, where: string): number {
239
+ const value = record[key]
240
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)
241
+ throw new RunContractError(`${where}.${key} must be a non-negative integer`)
242
+ return value
243
+ }
244
+
245
+ /** Decode a content-free plan reference. Lenient about unknown additive keys, never content. */
246
+ export function parseRunPlanRef(value: unknown): RunPlanRef {
247
+ if (!isRecord(value)) throw new RunContractError("run plan ref must be an object")
248
+ assertNoForbiddenKeys(value, "run plan ref")
249
+ const digest = str(value, "digest", "run plan ref")
250
+ if (!HEX64.test(digest)) throw new RunContractError("run plan ref.digest must be sha256 hex")
251
+ return {
252
+ id: str(value, "id", "run plan ref"),
253
+ digest,
254
+ nodeCount: nonNegInt(value, "nodeCount", "run plan ref"),
255
+ }
256
+ }
257
+
258
+ function parseCounters(value: unknown): RunCounters {
259
+ if (!isRecord(value)) throw new RunContractError("run snapshot.counters must be an object")
260
+ assertNoForbiddenKeys(value, "run snapshot.counters")
261
+ return {
262
+ total: nonNegInt(value, "total", "run snapshot.counters"),
263
+ completed: nonNegInt(value, "completed", "run snapshot.counters"),
264
+ failed: nonNegInt(value, "failed", "run snapshot.counters"),
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Decode a bounded run snapshot. Forward-compatible: unknown additive fields are ignored so an
270
+ * older client tolerates a newer host. Forbidden content keys are still rejected.
271
+ */
272
+ export function parseRunSnapshot(value: unknown): RunSnapshot {
273
+ if (!isRecord(value)) throw new RunContractError("run snapshot must be an object")
274
+ assertNoForbiddenKeys(value, "run snapshot")
275
+ if (value.version !== RUN_LIFECYCLE_VERSION)
276
+ throw new RunContractError(`run snapshot.version must be ${RUN_LIFECYCLE_VERSION}`)
277
+ const state = value.state
278
+ if (typeof state !== "string" || !RUN_LIFECYCLE_STATES.includes(state as RunLifecycleState))
279
+ throw new RunContractError("run snapshot.state is invalid")
280
+ const cursor = value.cursor
281
+ if (typeof cursor !== "number" || !Number.isSafeInteger(cursor) || cursor < CURSOR_BEFORE_ALL)
282
+ throw new RunContractError("run snapshot.cursor must be a safe integer >= -1")
283
+ const snapshot: RunSnapshot = {
284
+ version: RUN_LIFECYCLE_VERSION,
285
+ runId: str(value, "runId", "run snapshot"),
286
+ plan: parseRunPlanRef(value.plan),
287
+ state: state as RunLifecycleState,
288
+ cursor,
289
+ counters: parseCounters(value.counters),
290
+ updatedAt: nonNegInt(value, "updatedAt", "run snapshot"),
291
+ ...(value.failureCode !== undefined
292
+ ? { failureCode: str(value, "failureCode", "run snapshot") }
293
+ : {}),
294
+ }
295
+ return snapshot
296
+ }
297
+
298
+ /**
299
+ * Decode an evidence-event envelope. The envelope is forward-compatible, but the inner record is
300
+ * validated strictly by {@link parseRunEvidence}, and its identity must match `${runId}:${seq}`.
301
+ */
302
+ export function parseRunEvidenceEvent(value: unknown): RunEvidenceEvent {
303
+ if (!isRecord(value)) throw new RunContractError("run evidence event must be an object")
304
+ assertNoForbiddenKeys(value, "run evidence event")
305
+ if (value.version !== RUN_LIFECYCLE_VERSION)
306
+ throw new RunContractError(`run evidence event.version must be ${RUN_LIFECYCLE_VERSION}`)
307
+ const evidence = parseRunEvidence(value.evidence)
308
+ const expected = evidenceEventId(evidence.runId, evidence.seq)
309
+ if (value.eventId !== undefined && value.eventId !== expected)
310
+ throw new RunContractError("run evidence event.eventId must match the inner record identity")
311
+ return { version: RUN_LIFECYCLE_VERSION, eventId: expected, evidence }
312
+ }
313
+
314
+ /**
315
+ * Decode a handoff snapshot. Forward-compatible about unknown additive fields; forbidden content
316
+ * keys are rejected.
317
+ */
318
+ export function parseHandoffSnapshot(value: unknown): HandoffSnapshot {
319
+ if (!isRecord(value)) throw new RunContractError("handoff snapshot must be an object")
320
+ assertNoForbiddenKeys(value, "handoff snapshot")
321
+ if (value.version !== RUN_LIFECYCLE_VERSION)
322
+ throw new RunContractError(`handoff snapshot.version must be ${RUN_LIFECYCLE_VERSION}`)
323
+ const status = value.status
324
+ if (typeof status !== "string" || !HANDOFF_STATUSES.includes(status as HandoffStatus))
325
+ throw new RunContractError("handoff snapshot.status is invalid")
326
+ const snapshot: HandoffSnapshot = {
327
+ version: RUN_LIFECYCLE_VERSION,
328
+ runId: str(value, "runId", "handoff snapshot"),
329
+ nodeId: str(value, "nodeId", "handoff snapshot"),
330
+ seq: nonNegInt(value, "seq", "handoff snapshot"),
331
+ from: str(value, "from", "handoff snapshot"),
332
+ to: str(value, "to", "handoff snapshot"),
333
+ status: status as HandoffStatus,
334
+ ...(value.reason !== undefined ? { reason: str(value, "reason", "handoff snapshot") } : {}),
335
+ }
336
+ return snapshot
337
+ }
338
+
339
+ // ── Decision boundaries: approval and handoff lifecycles ────────────────────────────────────────
340
+ //
341
+ // A decision boundary is a paused point in a run awaiting a human (or delegated) decision: an
342
+ // approval to proceed, or a handoff between roles. Both are addressed by the same content-free
343
+ // {@link DecisionCoordinate} - run, node, capability, request id, a monotonic per-run child vector,
344
+ // and an absolute expiry - so a decision that does not match the live boundary, arrives after expiry,
345
+ // or replays a stale vector fails closed rather than resuming the wrong work. The lifecycles are
346
+ // modelled as explicit state machines below; a transition absent from the table is illegal.
347
+
348
+ /** The two boundary families a host pauses on. */
349
+ export type BoundaryKind = "approval" | "handoff"
350
+
351
+ /** Approval lifecycle. Terminal states accept no further transition. */
352
+ export type ApprovalLifecycleState = "pending" | "approved" | "denied" | "expired" | "cancelled"
353
+
354
+ /** Handoff lifecycle. `assigned` is the only non-pending live state; the rest are terminal. */
355
+ export type HandoffLifecycleState =
356
+ | "pending"
357
+ | "assigned"
358
+ | "accepted"
359
+ | "declined"
360
+ | "resolved"
361
+ | "expired"
362
+ | "cancelled"
363
+
364
+ /** The operations that drive a boundary. Not every op is legal from every state. */
365
+ export type BoundaryOp =
366
+ | "approve"
367
+ | "deny"
368
+ | "assign"
369
+ | "accept"
370
+ | "decline"
371
+ | "resolve"
372
+ | "expire"
373
+ | "cancel"
374
+
375
+ /**
376
+ * The negotiated command surface for the inbox: a superset of {@link BoundaryOp} plus the read verbs
377
+ * `list` and `inspect`. A host advertises the subset it supports; a client drives only the granted
378
+ * commands. Order here is the canonical advertisement order.
379
+ */
380
+ export const BOUNDARY_COMMANDS = [
381
+ "list",
382
+ "inspect",
383
+ "approve",
384
+ "deny",
385
+ "assign",
386
+ "resolve",
387
+ "expire",
388
+ "cancel",
389
+ ] as const
390
+
391
+ export type BoundaryCommand = (typeof BOUNDARY_COMMANDS)[number]
392
+
393
+ /** Stable, content-free reasons a boundary decision is refused. */
394
+ export type BoundaryRejection =
395
+ | "unknown_boundary"
396
+ | "identity_mismatch"
397
+ | "stale_vector"
398
+ | "expired"
399
+ | "illegal_transition"
400
+
401
+ /**
402
+ * The content-free identity of one decision boundary. `vector` is the run's monotonic child index at
403
+ * which the boundary opened; a decision must carry the boundary's exact vector, so a replayed or
404
+ * superseded decision (a lower or mismatched vector) is refused. `expiresAt` is an absolute epoch-ms
405
+ * deadline. No field carries a prompt, a reason-as-free-text, or a payload.
406
+ */
407
+ export interface DecisionCoordinate {
408
+ readonly runId: string
409
+ readonly nodeId: string
410
+ readonly capability: string
411
+ readonly requestId: string
412
+ readonly vector: number
413
+ readonly expiresAt: number
414
+ }
415
+
416
+ /** True when both coordinates address the same boundary at the same child vector. */
417
+ export function coordinatesMatch(a: DecisionCoordinate, b: DecisionCoordinate): boolean {
418
+ return (
419
+ a.runId === b.runId &&
420
+ a.nodeId === b.nodeId &&
421
+ a.capability === b.capability &&
422
+ a.requestId === b.requestId &&
423
+ a.vector === b.vector
424
+ )
425
+ }
426
+
427
+ /** True while `now` is strictly before the coordinate's expiry. At or past expiry fails closed. */
428
+ export function coordinateIsFresh(coordinate: DecisionCoordinate, now: number): boolean {
429
+ return now < coordinate.expiresAt
430
+ }
431
+
432
+ /**
433
+ * Monotonic child-vector check: a newly opened boundary must advance strictly past the run's last
434
+ * allocated vector. A non-advancing vector is a replay and is refused. `last` is `-1` before any
435
+ * boundary has opened for the run.
436
+ */
437
+ export function vectorAdvances(last: number, next: number): boolean {
438
+ return Number.isSafeInteger(next) && next > last
439
+ }
440
+
441
+ const APPROVAL_TRANSITIONS: Readonly<
442
+ Record<ApprovalLifecycleState, Partial<Record<BoundaryOp, ApprovalLifecycleState>>>
443
+ > = {
444
+ pending: { approve: "approved", deny: "denied", expire: "expired", cancel: "cancelled" },
445
+ approved: {},
446
+ denied: {},
447
+ expired: {},
448
+ cancelled: {},
449
+ }
450
+
451
+ const HANDOFF_TRANSITIONS: Readonly<
452
+ Record<HandoffLifecycleState, Partial<Record<BoundaryOp, HandoffLifecycleState>>>
453
+ > = {
454
+ pending: {
455
+ assign: "assigned",
456
+ accept: "accepted",
457
+ decline: "declined",
458
+ expire: "expired",
459
+ cancel: "cancelled",
460
+ },
461
+ assigned: {
462
+ accept: "accepted",
463
+ decline: "declined",
464
+ resolve: "resolved",
465
+ expire: "expired",
466
+ cancel: "cancelled",
467
+ },
468
+ accepted: { resolve: "resolved", expire: "expired", cancel: "cancelled" },
469
+ declined: {},
470
+ resolved: {},
471
+ expired: {},
472
+ cancelled: {},
473
+ }
474
+
475
+ /** The next approval state for an op, or `undefined` when the transition is illegal. */
476
+ export function nextApprovalState(
477
+ from: ApprovalLifecycleState,
478
+ op: BoundaryOp,
479
+ ): ApprovalLifecycleState | undefined {
480
+ return APPROVAL_TRANSITIONS[from][op]
481
+ }
482
+
483
+ /** The next handoff state for an op, or `undefined` when the transition is illegal. */
484
+ export function nextHandoffState(
485
+ from: HandoffLifecycleState,
486
+ op: BoundaryOp,
487
+ ): HandoffLifecycleState | undefined {
488
+ return HANDOFF_TRANSITIONS[from][op]
489
+ }
490
+
491
+ /** True once a boundary state accepts no further op. */
492
+ export function isTerminalApprovalState(state: ApprovalLifecycleState): boolean {
493
+ return Object.keys(APPROVAL_TRANSITIONS[state]).length === 0
494
+ }
495
+
496
+ /** True once a handoff state accepts no further op. */
497
+ export function isTerminalHandoffState(state: HandoffLifecycleState): boolean {
498
+ return Object.keys(HANDOFF_TRANSITIONS[state]).length === 0
499
+ }
500
+
501
+ /**
502
+ * Decode a decision coordinate. Content-free: forbidden payload keys are rejected, and only the
503
+ * declared structural fields are read. `vector` and `expiresAt` must be non-negative safe integers.
504
+ */
505
+ export function parseDecisionCoordinate(value: unknown): DecisionCoordinate {
506
+ if (!isRecord(value)) throw new RunContractError("decision coordinate must be an object")
507
+ assertNoForbiddenKeys(value, "decision coordinate")
508
+ return {
509
+ runId: str(value, "runId", "decision coordinate"),
510
+ nodeId: str(value, "nodeId", "decision coordinate"),
511
+ capability: str(value, "capability", "decision coordinate"),
512
+ requestId: str(value, "requestId", "decision coordinate"),
513
+ vector: nonNegInt(value, "vector", "decision coordinate"),
514
+ expiresAt: nonNegInt(value, "expiresAt", "decision coordinate"),
515
+ }
516
+ }