@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,447 @@
1
+ /**
2
+ * Orchestration value contracts: declarative run plans, content-free run evidence, artifact
3
+ * references, and the effect-key identity. Additive to the session protocol; carries no behavior,
4
+ * no runtime dependency, and - by construction - no payload content.
5
+ *
6
+ * The privacy invariant lives in the parsers here: {@link parseRunEvidence} and
7
+ * {@link parseArtifactRef} accept only the declared content-free keys and reject any record over
8
+ * {@link EVIDENCE_MAX_BYTES}. A prompt, model output, or tool payload cannot round-trip through
9
+ * these contracts. Raw payloads travel only through an {@link ArtifactPort} the caller owns.
10
+ */
11
+
12
+ /** Orchestration contract version. Additive to the session `AGENT_PROTOCOL_VERSION`. */
13
+ export const RUN_PLAN_VERSION = 1 as const
14
+
15
+ /** Hard cap on a single serialized evidence record. A content-free record never approaches this. */
16
+ export const EVIDENCE_MAX_BYTES = 4096
17
+
18
+ /**
19
+ * Keys that would carry payload content. They are never valid on evidence or artifact records and
20
+ * are rejected by the strict parsers even if they would otherwise fit the size cap.
21
+ */
22
+ export const FORBIDDEN_CONTENT_KEYS: readonly string[] = Object.freeze([
23
+ "prompt",
24
+ "text",
25
+ "input",
26
+ "output",
27
+ "report",
28
+ "message",
29
+ "payload",
30
+ "body",
31
+ "args",
32
+ "content",
33
+ "response",
34
+ "completion",
35
+ ])
36
+
37
+ // ── Artifact references ─────────────────────────────────────────────────────────────────────────
38
+
39
+ /** A content-free pointer to a payload the caller chose to retain out of band. */
40
+ export interface ArtifactRef {
41
+ /** Caller-namespaced id. Opaque to nifra. */
42
+ readonly id: string
43
+ /** SHA-256 hex of the payload, for integrity and correlation. Content-free. */
44
+ readonly digest: string
45
+ readonly bytes: number
46
+ readonly mediaType: string
47
+ /** Optional caller-opaque locator (e.g. a private URI). nifra never dereferences it. */
48
+ readonly locator?: string
49
+ }
50
+
51
+ /** What a payload is and where it came from, handed to the port at `put` time. */
52
+ export interface ArtifactContext {
53
+ readonly planDigest: string
54
+ readonly nodeId: string
55
+ /** e.g. "prompt" | "model_output" | "tool_input" | "tool_output". */
56
+ readonly kind: string
57
+ }
58
+
59
+ /**
60
+ * Caller-owned sink for raw payloads - the ONLY place payload bytes leave transient execution.
61
+ * The public repo ships only a discarding no-op and a disposable in-memory test port. No public
62
+ * implementation persists.
63
+ */
64
+ export interface ArtifactPort {
65
+ put(payload: Uint8Array, ctx: ArtifactContext): ArtifactRef | Promise<ArtifactRef>
66
+ }
67
+
68
+ // ── Effect key ──────────────────────────────────────────────────────────────────────────────────
69
+
70
+ /** The stable, content-free identity of one side-effecting node attempt-boundary. */
71
+ export interface NodeEffectKey {
72
+ /** SHA-256 hex over the canonical effect material. 64 lowercase hex chars. */
73
+ readonly digest: string
74
+ /** Non-secret structural coordinates, safe to log. */
75
+ readonly planDigest: string
76
+ readonly nodeId: string
77
+ }
78
+
79
+ // ── Run plan ────────────────────────────────────────────────────────────────────────────────────
80
+
81
+ /** Leaf kinds resolve a StepCatalog handler; structural kinds compose child nodes. */
82
+ export type RunLeafKind = "task" | "verify" | "approve" | "checkpoint" | "handoff" | "subagent"
83
+ export type RunStructuralKind = "sequence" | "parallel" | "retry" | "branch"
84
+
85
+ /** Declarative node kinds. Each maps to an existing workflow primitive at compile time. */
86
+ export type RunNodeKind = RunLeafKind | RunStructuralKind
87
+
88
+ /** Bounded retry policy for a leaf node. Attempts map to the kernel's retry step. */
89
+ export interface RunRetryPolicy {
90
+ readonly attempts: number
91
+ readonly backoffMs?: number
92
+ }
93
+
94
+ /** A leaf plan node. `step` names a handler resolved locally through a StepCatalog. */
95
+ export interface RunLeafNode {
96
+ readonly id: string
97
+ readonly kind: RunLeafKind
98
+ /** StepCatalog key. The handler is registered locally and never persisted in the plan. */
99
+ readonly step: string
100
+ /** Only meaningful on top-level nodes; nested children may not declare it. */
101
+ readonly dependsOn?: readonly string[]
102
+ /** Required for `approve`; ignored otherwise. */
103
+ readonly reason?: string
104
+ /** When set, the compiler asserts the resolved catalog step declares this exact version. */
105
+ readonly stepVersion?: number
106
+ /** Wraps this leaf's compiled step in a bounded retry. */
107
+ readonly retry?: RunRetryPolicy
108
+ }
109
+
110
+ /** An ordered composite. Children run in declaration order. */
111
+ export interface RunSequenceNode {
112
+ readonly id: string
113
+ readonly kind: "sequence"
114
+ readonly children: readonly RunNode[]
115
+ readonly dependsOn?: readonly string[]
116
+ }
117
+
118
+ /** A bounded fan-out. Effective concurrency is the lower of this, the host, and the child count. */
119
+ export interface RunParallelNode {
120
+ readonly id: string
121
+ readonly kind: "parallel"
122
+ readonly children: readonly RunNode[]
123
+ readonly maxConcurrency?: number
124
+ readonly dependsOn?: readonly string[]
125
+ }
126
+
127
+ /** A bounded retry wrapping a single child node. */
128
+ export interface RunRetryNode {
129
+ readonly id: string
130
+ readonly kind: "retry"
131
+ readonly child: RunNode
132
+ readonly attempts: number
133
+ readonly backoffMs?: number
134
+ readonly dependsOn?: readonly string[]
135
+ }
136
+
137
+ /** A conditional. `step` names a predicate catalog handler selecting `then` or `otherwise`. */
138
+ export interface RunBranchNode {
139
+ readonly id: string
140
+ readonly kind: "branch"
141
+ readonly step: string
142
+ readonly then: RunNode
143
+ readonly otherwise?: RunNode
144
+ readonly dependsOn?: readonly string[]
145
+ }
146
+
147
+ /** A serializable, closure-free plan node. Structural kinds nest into a tree. */
148
+ export type RunNode = RunLeafNode | RunSequenceNode | RunParallelNode | RunRetryNode | RunBranchNode
149
+
150
+ /** A serializable, closure-free run plan. Top-level nodes form a DAG; each node may nest a tree. */
151
+ export interface RunPlan {
152
+ readonly version: typeof RUN_PLAN_VERSION
153
+ readonly id: string
154
+ readonly nodes: readonly RunNode[]
155
+ }
156
+
157
+ // ── Run evidence ────────────────────────────────────────────────────────────────────────────────
158
+
159
+ export type RunEvidenceStatus = "started" | "completed" | "failed"
160
+
161
+ /** The content-free projection of one run transition. Every field is id/hash/counter/status/timing. */
162
+ export interface RunEvidence {
163
+ readonly version: typeof RUN_PLAN_VERSION
164
+ readonly runId: string
165
+ readonly planDigest: string
166
+ readonly nodeId: string
167
+ readonly status: RunEvidenceStatus
168
+ readonly seq: number
169
+ /** NodeEffectKey.digest when the node declared an effect selector; absent otherwise. */
170
+ readonly effectKey?: string
171
+ /** Whether the step declared an idempotency selector. */
172
+ readonly idempotent: boolean
173
+ readonly artifacts?: readonly ArtifactRef[]
174
+ readonly errorCode?: string
175
+ readonly durationMs?: number
176
+ }
177
+
178
+ // ── Strict parsers ──────────────────────────────────────────────────────────────────────────────
179
+
180
+ /** Thrown by every parser in this module on malformed or content-bearing input. Fails closed. */
181
+ export class RunContractError extends Error {
182
+ constructor(readonly reason: string) {
183
+ super(`orchestration contract: ${reason}`)
184
+ this.name = "RunContractError"
185
+ }
186
+ }
187
+
188
+ function isRecord(value: unknown): value is Record<string, unknown> {
189
+ return typeof value === "object" && value !== null && !Array.isArray(value)
190
+ }
191
+
192
+ function assertNoForbiddenKeys(record: Record<string, unknown>, where: string): void {
193
+ for (const key of FORBIDDEN_CONTENT_KEYS) {
194
+ if (key in record) throw new RunContractError(`${where} carries forbidden content key '${key}'`)
195
+ }
196
+ }
197
+
198
+ function assertOnlyKeys(
199
+ record: Record<string, unknown>,
200
+ allowed: readonly string[],
201
+ where: string,
202
+ ): void {
203
+ for (const key of Object.keys(record)) {
204
+ if (!allowed.includes(key)) throw new RunContractError(`${where} has unknown key '${key}'`)
205
+ }
206
+ }
207
+
208
+ const HEX64 = /^[0-9a-f]{64}$/
209
+
210
+ function str(record: Record<string, unknown>, key: string, where: string): string {
211
+ const value = record[key]
212
+ if (typeof value !== "string" || value.length === 0)
213
+ throw new RunContractError(`${where}.${key} must be a non-empty string`)
214
+ return value
215
+ }
216
+
217
+ function int(record: Record<string, unknown>, key: string, where: string): number {
218
+ const value = record[key]
219
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)
220
+ throw new RunContractError(`${where}.${key} must be a non-negative integer`)
221
+ return value
222
+ }
223
+
224
+ /** Parse a content-free artifact reference. Rejects any non-schema or content key. */
225
+ export function parseArtifactRef(value: unknown): ArtifactRef {
226
+ if (!isRecord(value)) throw new RunContractError("artifact ref must be an object")
227
+ assertOnlyKeys(value, ["id", "digest", "bytes", "mediaType", "locator"], "artifact ref")
228
+ assertNoForbiddenKeys(value, "artifact ref")
229
+ const digest = str(value, "digest", "artifact ref")
230
+ if (!HEX64.test(digest)) throw new RunContractError("artifact ref.digest must be sha256 hex")
231
+ const ref: ArtifactRef = {
232
+ id: str(value, "id", "artifact ref"),
233
+ digest,
234
+ bytes: int(value, "bytes", "artifact ref"),
235
+ mediaType: str(value, "mediaType", "artifact ref"),
236
+ }
237
+ if (value.locator !== undefined) {
238
+ if (typeof value.locator !== "string")
239
+ throw new RunContractError("artifact ref.locator must be a string")
240
+ return { ...ref, locator: value.locator }
241
+ }
242
+ return ref
243
+ }
244
+
245
+ const LEAF_KINDS: readonly RunLeafKind[] = [
246
+ "task",
247
+ "verify",
248
+ "approve",
249
+ "checkpoint",
250
+ "handoff",
251
+ "subagent",
252
+ ]
253
+ const STRUCTURAL_KINDS: readonly RunStructuralKind[] = ["sequence", "parallel", "retry", "branch"]
254
+
255
+ function posInt(record: Record<string, unknown>, key: string, where: string): number {
256
+ const value = record[key]
257
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1)
258
+ throw new RunContractError(`${where}.${key} must be a positive integer`)
259
+ return value
260
+ }
261
+
262
+ function parseDependsOn(
263
+ raw: Record<string, unknown>,
264
+ where: string,
265
+ allowDepends: boolean,
266
+ ): readonly string[] | undefined {
267
+ if (raw.dependsOn === undefined) return undefined
268
+ if (!allowDepends) throw new RunContractError(`${where} is nested and may not declare dependsOn`)
269
+ if (!Array.isArray(raw.dependsOn) || raw.dependsOn.some((d) => typeof d !== "string"))
270
+ throw new RunContractError(`${where}.dependsOn must be a string array`)
271
+ return [...(raw.dependsOn as readonly string[])]
272
+ }
273
+
274
+ function parseRetryPolicy(value: unknown, where: string): RunRetryPolicy {
275
+ if (!isRecord(value)) throw new RunContractError(`${where}.retry must be an object`)
276
+ assertOnlyKeys(value, ["attempts", "backoffMs"], `${where}.retry`)
277
+ const attempts = posInt(value, "attempts", `${where}.retry`)
278
+ if (attempts > 16) throw new RunContractError(`${where}.retry.attempts must be 1..16`)
279
+ if (value.backoffMs === undefined) return { attempts }
280
+ return { attempts, backoffMs: int(value, "backoffMs", `${where}.retry`) }
281
+ }
282
+
283
+ /** Parse one node (recursively). `allowDepends` is true only for top-level nodes. */
284
+ function parseNode(raw: unknown, where: string, allowDepends: boolean, ids: Set<string>): RunNode {
285
+ if (!isRecord(raw)) throw new RunContractError(`${where} must be an object`)
286
+ const kind = raw.kind
287
+ if (typeof kind !== "string") throw new RunContractError(`${where}.kind must be a string`)
288
+ const isLeaf = LEAF_KINDS.includes(kind as RunLeafKind)
289
+ if (!isLeaf && !STRUCTURAL_KINDS.includes(kind as RunStructuralKind))
290
+ throw new RunContractError(`${where}.kind '${kind}' is invalid`)
291
+
292
+ const allowed =
293
+ kind === "sequence"
294
+ ? ["id", "kind", "children", "dependsOn"]
295
+ : kind === "parallel"
296
+ ? ["id", "kind", "children", "maxConcurrency", "dependsOn"]
297
+ : kind === "retry"
298
+ ? ["id", "kind", "child", "attempts", "backoffMs", "dependsOn"]
299
+ : kind === "branch"
300
+ ? ["id", "kind", "step", "then", "otherwise", "dependsOn"]
301
+ : ["id", "kind", "step", "dependsOn", "reason", "stepVersion", "retry"]
302
+ assertOnlyKeys(raw, allowed, where)
303
+ assertNoForbiddenKeys(raw, where)
304
+
305
+ const id = str(raw, "id", where)
306
+ if (ids.has(id)) throw new RunContractError(`duplicate node id '${id}'`)
307
+ ids.add(id)
308
+ const dependsOn = parseDependsOn(raw, where, allowDepends)
309
+ const withDeps = <T extends { readonly kind: RunNodeKind }>(node: T): T =>
310
+ dependsOn === undefined ? node : { ...node, dependsOn }
311
+
312
+ if (isLeaf) {
313
+ const leaf: RunLeafNode = { id, kind: kind as RunLeafKind, step: str(raw, "step", where) }
314
+ let out = leaf
315
+ if (raw.reason !== undefined) {
316
+ if (typeof raw.reason !== "string")
317
+ throw new RunContractError(`${where}.reason must be a string`)
318
+ out = { ...out, reason: raw.reason }
319
+ }
320
+ if (kind === "approve" && out.reason === undefined)
321
+ throw new RunContractError(`${where} of kind 'approve' requires a reason`)
322
+ if (raw.stepVersion !== undefined)
323
+ out = { ...out, stepVersion: posInt(raw, "stepVersion", where) }
324
+ if (raw.retry !== undefined) out = { ...out, retry: parseRetryPolicy(raw.retry, where) }
325
+ return withDeps(out)
326
+ }
327
+
328
+ if (kind === "sequence" || kind === "parallel") {
329
+ if (!Array.isArray(raw.children) || raw.children.length === 0)
330
+ throw new RunContractError(`${where}.children must be a non-empty array`)
331
+ const children = raw.children.map((child, i) =>
332
+ parseNode(child, `${where}.children[${i}]`, false, ids),
333
+ )
334
+ if (kind === "sequence") return withDeps({ id, kind, children })
335
+ const node: RunParallelNode = { id, kind, children }
336
+ return withDeps(
337
+ raw.maxConcurrency === undefined
338
+ ? node
339
+ : { ...node, maxConcurrency: posInt(raw, "maxConcurrency", where) },
340
+ )
341
+ }
342
+
343
+ if (kind === "retry") {
344
+ const attempts = posInt(raw, "attempts", where)
345
+ if (attempts > 16) throw new RunContractError(`${where}.attempts must be 1..16`)
346
+ const child = parseNode(raw.child, `${where}.child`, false, ids)
347
+ const node: RunRetryNode = { id, kind, child, attempts }
348
+ return withDeps(
349
+ raw.backoffMs === undefined ? node : { ...node, backoffMs: int(raw, "backoffMs", where) },
350
+ )
351
+ }
352
+
353
+ // branch
354
+ const then = parseNode(raw.then, `${where}.then`, false, ids)
355
+ const node: RunBranchNode = { id, kind: "branch", step: str(raw, "step", where), then }
356
+ return withDeps(
357
+ raw.otherwise === undefined
358
+ ? node
359
+ : { ...node, otherwise: parseNode(raw.otherwise, `${where}.otherwise`, false, ids) },
360
+ )
361
+ }
362
+
363
+ /** Parse a declarative run plan. Rejects closures, unknown keys, and structural errors. */
364
+ export function parseRunPlan(value: unknown): RunPlan {
365
+ if (!isRecord(value)) throw new RunContractError("run plan must be an object")
366
+ assertOnlyKeys(value, ["version", "id", "nodes"], "run plan")
367
+ if (value.version !== RUN_PLAN_VERSION)
368
+ throw new RunContractError(`run plan.version must be ${RUN_PLAN_VERSION}`)
369
+ if (!Array.isArray(value.nodes) || value.nodes.length === 0)
370
+ throw new RunContractError("run plan.nodes must be a non-empty array")
371
+ const ids = new Set<string>()
372
+ const nodes = value.nodes.map((raw, index) => parseNode(raw, `node[${index}]`, true, ids))
373
+ const topLevel = new Set(nodes.map((node) => node.id))
374
+ for (const node of nodes) {
375
+ for (const dep of node.dependsOn ?? []) {
376
+ if (dep === node.id) throw new RunContractError(`node '${node.id}' depends on itself`)
377
+ if (!topLevel.has(dep))
378
+ throw new RunContractError(`node '${node.id}' depends on unknown '${dep}'`)
379
+ }
380
+ }
381
+ return { version: RUN_PLAN_VERSION, id: str(value, "id", "run plan"), nodes }
382
+ }
383
+
384
+ /** Parse a content-free evidence record. Rejects content keys and any record over the size cap. */
385
+ export function parseRunEvidence(value: unknown): RunEvidence {
386
+ if (!isRecord(value)) throw new RunContractError("evidence must be an object")
387
+ assertOnlyKeys(
388
+ value,
389
+ [
390
+ "version",
391
+ "runId",
392
+ "planDigest",
393
+ "nodeId",
394
+ "status",
395
+ "seq",
396
+ "effectKey",
397
+ "idempotent",
398
+ "artifacts",
399
+ "errorCode",
400
+ "durationMs",
401
+ ],
402
+ "evidence",
403
+ )
404
+ assertNoForbiddenKeys(value, "evidence")
405
+ if (value.version !== RUN_PLAN_VERSION)
406
+ throw new RunContractError(`evidence.version must be ${RUN_PLAN_VERSION}`)
407
+ const status = value.status
408
+ if (status !== "started" && status !== "completed" && status !== "failed")
409
+ throw new RunContractError("evidence.status is invalid")
410
+ if (typeof value.idempotent !== "boolean")
411
+ throw new RunContractError("evidence.idempotent must be a boolean")
412
+ const evidence: RunEvidence = {
413
+ version: RUN_PLAN_VERSION,
414
+ runId: str(value, "runId", "evidence"),
415
+ planDigest: str(value, "planDigest", "evidence"),
416
+ nodeId: str(value, "nodeId", "evidence"),
417
+ status,
418
+ seq: int(value, "seq", "evidence"),
419
+ idempotent: value.idempotent,
420
+ ...(value.effectKey !== undefined
421
+ ? { effectKey: parseDigest(value.effectKey, "effectKey") }
422
+ : {}),
423
+ ...(value.errorCode !== undefined ? { errorCode: str(value, "errorCode", "evidence") } : {}),
424
+ ...(value.durationMs !== undefined ? { durationMs: int(value, "durationMs", "evidence") } : {}),
425
+ ...(value.artifacts !== undefined ? { artifacts: parseArtifacts(value.artifacts) } : {}),
426
+ }
427
+ assertEvidenceSize(evidence)
428
+ return evidence
429
+ }
430
+
431
+ function parseDigest(value: unknown, field: string): string {
432
+ if (typeof value !== "string" || !HEX64.test(value))
433
+ throw new RunContractError(`evidence.${field} must be sha256 hex`)
434
+ return value
435
+ }
436
+
437
+ function parseArtifacts(value: unknown): readonly ArtifactRef[] {
438
+ if (!Array.isArray(value)) throw new RunContractError("evidence.artifacts must be an array")
439
+ return value.map(parseArtifactRef)
440
+ }
441
+
442
+ /** Assert a record serializes within the hard cap. Public reference records are content-free. */
443
+ export function assertEvidenceSize(evidence: RunEvidence): void {
444
+ const bytes = new TextEncoder().encode(JSON.stringify(evidence)).length
445
+ if (bytes > EVIDENCE_MAX_BYTES)
446
+ throw new RunContractError(`evidence record ${bytes}B exceeds ${EVIDENCE_MAX_BYTES}B cap`)
447
+ }