@nifrajs/agent 3.1.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,444 @@
1
+ /**
2
+ * Unified capability descriptor registry.
3
+ *
4
+ * A {@link CapabilityDescriptor} is the content-free identity card of one invokable capability - a
5
+ * core tool, an MCP tool, a coding-agent extension, a model adapter, or a deployment adapter. It
6
+ * carries a stable name, a version, a kind, a schema digest, the capabilities it requires, and the
7
+ * host-policy classes (approval, retry, idempotency, isolation) the host uses to admit it. It never
8
+ * carries a description, an input or output payload, a prompt, or any other content: the digest is
9
+ * taken over the input JSON schema, so two producers of the same capability agree on identity
10
+ * without either exposing a request body.
11
+ *
12
+ * A {@link RegistrySnapshot} composes many descriptors into a deterministic, collision-checked set
13
+ * with a single snapshot digest. Canonical ordering makes the digest independent of input order, so
14
+ * reordering declarations can never change it. Every failure raises a {@link RegistryError} with a
15
+ * stable {@link RegistryErrorCode}; nothing here throws a message that could carry content.
16
+ *
17
+ * The package depends only on `@nifrajs/core`. The MCP and coding-agent adapters live in their own
18
+ * packages and consume these contracts through the declared `@nifrajs/agent` edge - never the
19
+ * reverse - so neither this package nor the protocol ever imports MCP transport or the coding agent.
20
+ */
21
+
22
+ import type { ToolContract } from "@nifrajs/core/tool-contract"
23
+ import { toolInputJsonSchema } from "@nifrajs/core/tool-contract"
24
+
25
+ export const CAPABILITY_DESCRIPTOR_VERSION = 1 as const
26
+ export const REGISTRY_SNAPSHOT_VERSION = 1 as const
27
+
28
+ /** Producer families a descriptor can describe. A value outside this set fails `unsupported_kind`. */
29
+ export type CapabilityKind =
30
+ | "tool"
31
+ | "mcp-tool"
32
+ | "extension"
33
+ | "model-adapter"
34
+ | "deployment-adapter"
35
+
36
+ const CAPABILITY_KINDS: ReadonlySet<string> = new Set<CapabilityKind>([
37
+ "tool",
38
+ "mcp-tool",
39
+ "extension",
40
+ "model-adapter",
41
+ "deployment-adapter",
42
+ ])
43
+
44
+ /** Host approval requirement. Mirrors the core tool approval policy; a numeric level is not content. */
45
+ export type ApprovalClass =
46
+ | { readonly kind: "none" }
47
+ | { readonly kind: "required" }
48
+ | { readonly kind: "threshold"; readonly level: number }
49
+
50
+ /** Whether a failed invocation may be retried. A capability is retry-eligible only when idempotent. */
51
+ export type RetryClass = "none" | "idempotent"
52
+ const RETRY_CLASSES: ReadonlySet<string> = new Set<RetryClass>(["none", "idempotent"])
53
+
54
+ /** Idempotency guarantee a capability declares, mirroring the tool idempotency scope. */
55
+ export type IdempotencyClass = "none" | "request" | "durable"
56
+ const IDEMPOTENCY_CLASSES: ReadonlySet<string> = new Set<IdempotencyClass>([
57
+ "none",
58
+ "request",
59
+ "durable",
60
+ ])
61
+
62
+ /** Isolation the host must provide to invoke the capability. */
63
+ export type IsolationClass = "inherit" | "process" | "sandbox"
64
+ const ISOLATION_CLASSES: ReadonlySet<string> = new Set<IsolationClass>([
65
+ "inherit",
66
+ "process",
67
+ "sandbox",
68
+ ])
69
+
70
+ export interface CapabilityDescriptor {
71
+ readonly descriptorVersion: typeof CAPABILITY_DESCRIPTOR_VERSION
72
+ readonly kind: CapabilityKind
73
+ readonly name: string
74
+ readonly version: string
75
+ /** SHA-256 hex over the canonical input JSON schema. Identity without exposing a request body. */
76
+ readonly schemaDigest: string
77
+ readonly requiredCapabilities: readonly string[]
78
+ readonly approval: ApprovalClass
79
+ readonly retry: RetryClass
80
+ readonly idempotency: IdempotencyClass
81
+ readonly isolation: IsolationClass
82
+ }
83
+
84
+ export interface RegistrySnapshot {
85
+ readonly version: typeof REGISTRY_SNAPSHOT_VERSION
86
+ /** SHA-256 hex over the canonically ordered descriptor set. Independent of input order. */
87
+ readonly digest: string
88
+ readonly descriptors: readonly CapabilityDescriptor[]
89
+ }
90
+
91
+ export type RegistryErrorCode =
92
+ | "invalid_descriptor"
93
+ | "unsupported_kind"
94
+ | "missing_version"
95
+ | "invalid_name"
96
+ | "invalid_capability"
97
+ | "content_field"
98
+ | "invalid_schema_digest"
99
+ | "invalid_approval"
100
+ | "descriptor_collision"
101
+ | "schema_drift"
102
+ | "capability_escalation"
103
+
104
+ /** A stable, content-free failure. `code` is the machine-addressable reason; the message is generic. */
105
+ export class RegistryError extends Error {
106
+ readonly code: RegistryErrorCode
107
+
108
+ constructor(code: RegistryErrorCode, message = code) {
109
+ super(message)
110
+ this.code = code
111
+ this.name = "RegistryError"
112
+ }
113
+ }
114
+
115
+ const NAME_TOKEN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
116
+ const CAPABILITY_TOKEN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/
117
+ const VERSION_TOKEN = /^[A-Za-z0-9][A-Za-z0-9._:+-]{0,63}$/
118
+ const SCHEMA_DIGEST = /^[0-9a-f]{64}$/
119
+
120
+ const DESCRIPTOR_KEYS: ReadonlySet<string> = new Set([
121
+ "descriptorVersion",
122
+ "kind",
123
+ "name",
124
+ "version",
125
+ "schemaDigest",
126
+ "requiredCapabilities",
127
+ "approval",
128
+ "retry",
129
+ "idempotency",
130
+ "isolation",
131
+ ])
132
+
133
+ /**
134
+ * Keys that would carry content or a payload. Their mere presence on a descriptor object is a
135
+ * boundary violation and fails `content_field`, distinct from an unknown structural field.
136
+ */
137
+ const CONTENT_KEYS: ReadonlySet<string> = new Set([
138
+ "description",
139
+ "instruction",
140
+ "input",
141
+ "output",
142
+ "inputSchema",
143
+ "outputSchema",
144
+ "content",
145
+ "prompt",
146
+ "text",
147
+ "message",
148
+ "arguments",
149
+ "args",
150
+ "params",
151
+ "parameters",
152
+ "result",
153
+ "payload",
154
+ "body",
155
+ "data",
156
+ ])
157
+
158
+ const encoder = new TextEncoder()
159
+
160
+ /** SHA-256 hex of a UTF-8 string. Collision-resistant, so a digest cannot be forged from content. */
161
+ async function sha256HexOf(text: string): Promise<string> {
162
+ const digest = await crypto.subtle.digest("SHA-256", encoder.encode(text))
163
+ const view = new Uint8Array(digest)
164
+ let hex = ""
165
+ for (let i = 0; i < view.length; i++) hex += (view[i] as number).toString(16).padStart(2, "0")
166
+ return hex
167
+ }
168
+
169
+ /** Deterministic JSON with recursively sorted object keys. Arrays keep order; non-finite rejected. */
170
+ function canonicalJson(value: unknown): string {
171
+ if (value === null || typeof value === "boolean" || typeof value === "string")
172
+ return JSON.stringify(value)
173
+ if (typeof value === "number") {
174
+ if (!Number.isFinite(value)) throw new TypeError("non-finite JSON number")
175
+ return JSON.stringify(value)
176
+ }
177
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`
178
+ if (typeof value === "object") {
179
+ const record = value as Record<string, unknown>
180
+ const keys = Object.keys(record)
181
+ .filter((key) => record[key] !== undefined)
182
+ .sort()
183
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`
184
+ }
185
+ throw new TypeError(`cannot canonicalize ${typeof value}`)
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 parseKind(value: unknown): CapabilityKind {
193
+ if (typeof value !== "string" || !CAPABILITY_KINDS.has(value))
194
+ throw new RegistryError("unsupported_kind")
195
+ return value as CapabilityKind
196
+ }
197
+
198
+ function parseName(value: unknown): string {
199
+ if (typeof value !== "string" || !NAME_TOKEN.test(value)) throw new RegistryError("invalid_name")
200
+ return value
201
+ }
202
+
203
+ function parseVersion(value: unknown): string {
204
+ if (typeof value !== "string" || value.length === 0) throw new RegistryError("missing_version")
205
+ if (!VERSION_TOKEN.test(value)) throw new RegistryError("missing_version")
206
+ return value
207
+ }
208
+
209
+ function parseRequiredCapabilities(value: unknown): readonly string[] {
210
+ if (value === undefined) return Object.freeze([])
211
+ if (!Array.isArray(value)) throw new RegistryError("invalid_capability")
212
+ const seen = new Set<string>()
213
+ for (const entry of value) {
214
+ if (typeof entry !== "string" || !CAPABILITY_TOKEN.test(entry))
215
+ throw new RegistryError("invalid_capability")
216
+ seen.add(entry)
217
+ }
218
+ return Object.freeze([...seen].sort())
219
+ }
220
+
221
+ function parseApproval(value: unknown): ApprovalClass {
222
+ if (value === undefined) return { kind: "none" }
223
+ if (!isRecord(value)) throw new RegistryError("invalid_approval")
224
+ if (value.kind === "none") return { kind: "none" }
225
+ if (value.kind === "required") return { kind: "required" }
226
+ if (value.kind === "threshold") {
227
+ const level = value.level
228
+ if (typeof level !== "number" || !Number.isInteger(level) || level < 0)
229
+ throw new RegistryError("invalid_approval")
230
+ return { kind: "threshold", level }
231
+ }
232
+ throw new RegistryError("invalid_approval")
233
+ }
234
+
235
+ function parseMember<T extends string>(
236
+ value: unknown,
237
+ members: ReadonlySet<string>,
238
+ fallback: T,
239
+ code: RegistryErrorCode,
240
+ ): T {
241
+ if (value === undefined) return fallback
242
+ if (typeof value !== "string" || !members.has(value)) throw new RegistryError(code)
243
+ return value as T
244
+ }
245
+
246
+ function assertNoContentFields(record: Record<string, unknown>): void {
247
+ for (const key of Object.keys(record)) {
248
+ if (DESCRIPTOR_KEYS.has(key)) continue
249
+ if (CONTENT_KEYS.has(key)) throw new RegistryError("content_field")
250
+ throw new RegistryError("invalid_descriptor")
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Validate an untrusted value as a {@link CapabilityDescriptor}, normalizing capability order. Missing,
256
+ * unknown, or content-bearing fields are rejected with a stable code. This is the single admission
257
+ * point: every adapter output and every wire value passes through it before it enters a snapshot.
258
+ */
259
+ export function parseCapabilityDescriptor(value: unknown): CapabilityDescriptor {
260
+ if (!isRecord(value)) throw new RegistryError("invalid_descriptor")
261
+ assertNoContentFields(value)
262
+ if (value.descriptorVersion !== CAPABILITY_DESCRIPTOR_VERSION)
263
+ throw new RegistryError("invalid_descriptor")
264
+ const kind = parseKind(value.kind)
265
+ const name = parseName(value.name)
266
+ const version = parseVersion(value.version)
267
+ if (typeof value.schemaDigest !== "string" || !SCHEMA_DIGEST.test(value.schemaDigest))
268
+ throw new RegistryError("invalid_schema_digest")
269
+ const requiredCapabilities = parseRequiredCapabilities(value.requiredCapabilities)
270
+ const approval = parseApproval(value.approval)
271
+ const retry = parseMember<RetryClass>(value.retry, RETRY_CLASSES, "none", "invalid_descriptor")
272
+ const idempotency = parseMember<IdempotencyClass>(
273
+ value.idempotency,
274
+ IDEMPOTENCY_CLASSES,
275
+ "none",
276
+ "invalid_descriptor",
277
+ )
278
+ const isolation = parseMember<IsolationClass>(
279
+ value.isolation,
280
+ ISOLATION_CLASSES,
281
+ "inherit",
282
+ "invalid_descriptor",
283
+ )
284
+ return Object.freeze({
285
+ descriptorVersion: CAPABILITY_DESCRIPTOR_VERSION,
286
+ kind,
287
+ name,
288
+ version,
289
+ schemaDigest: value.schemaDigest,
290
+ requiredCapabilities,
291
+ approval: Object.freeze(approval),
292
+ retry,
293
+ idempotency,
294
+ isolation,
295
+ })
296
+ }
297
+
298
+ /** The fields an adapter supplies. The schema digest is derived from `inputSchema`, never passed in. */
299
+ export interface DescriptorInput {
300
+ readonly kind: CapabilityKind
301
+ readonly name: string
302
+ readonly version?: string
303
+ /** The capability's input JSON schema. Digested for identity; the schema itself is not stored. */
304
+ readonly inputSchema: Record<string, unknown>
305
+ readonly requiredCapabilities?: readonly string[]
306
+ readonly approval?: ApprovalClass
307
+ readonly retry?: RetryClass
308
+ readonly idempotency?: IdempotencyClass
309
+ readonly isolation?: IsolationClass
310
+ }
311
+
312
+ const DEFAULT_DESCRIPTOR_VERSION = "1.0.0"
313
+
314
+ /**
315
+ * Build a validated descriptor from adapter fields, digesting the input schema. Two adapters that
316
+ * describe the same capability (same kind, name, version, schema, and classes) produce byte-identical
317
+ * descriptors and therefore the same digest, which is what makes cross-package parity checkable.
318
+ */
319
+ export async function composeDescriptor(input: DescriptorInput): Promise<CapabilityDescriptor> {
320
+ const kind = parseKind(input.kind)
321
+ const name = parseName(input.name)
322
+ const version = parseVersion(input.version ?? DEFAULT_DESCRIPTOR_VERSION)
323
+ if (!isRecord(input.inputSchema)) throw new RegistryError("invalid_descriptor")
324
+ const schemaDigest = await sha256HexOf(canonicalJson(input.inputSchema))
325
+ const requiredCapabilities = parseRequiredCapabilities(input.requiredCapabilities)
326
+ const approval = parseApproval(input.approval)
327
+ const retry = parseMember<RetryClass>(input.retry, RETRY_CLASSES, "none", "invalid_descriptor")
328
+ const idempotency = parseMember<IdempotencyClass>(
329
+ input.idempotency,
330
+ IDEMPOTENCY_CLASSES,
331
+ "none",
332
+ "invalid_descriptor",
333
+ )
334
+ const isolation = parseMember<IsolationClass>(
335
+ input.isolation,
336
+ ISOLATION_CLASSES,
337
+ "inherit",
338
+ "invalid_descriptor",
339
+ )
340
+ return Object.freeze({
341
+ descriptorVersion: CAPABILITY_DESCRIPTOR_VERSION,
342
+ kind,
343
+ name,
344
+ version,
345
+ schemaDigest,
346
+ requiredCapabilities,
347
+ approval: Object.freeze(approval),
348
+ retry,
349
+ idempotency,
350
+ isolation,
351
+ })
352
+ }
353
+
354
+ export interface ToolDescriptorOptions {
355
+ /** Kind to record. Defaults to `tool`; the MCP adapter passes `mcp-tool` for the same contract. */
356
+ readonly kind?: CapabilityKind
357
+ readonly version?: string
358
+ readonly isolation?: IsolationClass
359
+ }
360
+
361
+ /**
362
+ * Adapt a Nifra core {@link ToolContract} into a descriptor without touching its execution contract.
363
+ * The schema digest is taken over the tool's own input JSON schema, retry follows idempotency, and
364
+ * the approval policy is carried through unchanged, so the descriptor is a faithful, content-free
365
+ * projection of the contract the runtime already enforces.
366
+ */
367
+ export function descriptorFromTool<Input, Output>(
368
+ tool: ToolContract<Input, Output>,
369
+ options: ToolDescriptorOptions = {},
370
+ ): Promise<CapabilityDescriptor> {
371
+ return composeDescriptor({
372
+ kind: options.kind ?? "tool",
373
+ name: tool.name,
374
+ ...(options.version === undefined ? {} : { version: options.version }),
375
+ inputSchema: toolInputJsonSchema(tool),
376
+ requiredCapabilities: [tool.capability],
377
+ approval: tool.approval,
378
+ retry: tool.idempotency === undefined ? "none" : "idempotent",
379
+ idempotency: tool.idempotency === undefined ? "none" : tool.idempotency.scope,
380
+ isolation: options.isolation ?? "inherit",
381
+ })
382
+ }
383
+
384
+ function identityKey(descriptor: CapabilityDescriptor): string {
385
+ return `${descriptor.kind}${descriptor.name}`
386
+ }
387
+
388
+ function compareDescriptors(a: CapabilityDescriptor, b: CapabilityDescriptor): number {
389
+ if (a.kind !== b.kind) return a.kind < b.kind ? -1 : 1
390
+ if (a.name !== b.name) return a.name < b.name ? -1 : 1
391
+ if (a.version !== b.version) return a.version < b.version ? -1 : 1
392
+ return 0
393
+ }
394
+
395
+ function canonicalDescriptor(descriptor: CapabilityDescriptor): Record<string, unknown> {
396
+ return {
397
+ descriptorVersion: descriptor.descriptorVersion,
398
+ kind: descriptor.kind,
399
+ name: descriptor.name,
400
+ version: descriptor.version,
401
+ schemaDigest: descriptor.schemaDigest,
402
+ requiredCapabilities: [...descriptor.requiredCapabilities],
403
+ approval: { ...descriptor.approval },
404
+ retry: descriptor.retry,
405
+ idempotency: descriptor.idempotency,
406
+ isolation: descriptor.isolation,
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Compose a deterministic registry snapshot. Descriptors are validated, checked for identity
412
+ * collisions, canonically ordered, and digested. Two descriptors that share a (kind, name) identity
413
+ * fail `descriptor_collision` when their schema digests agree and `schema_drift` when they differ -
414
+ * a drifted schema is a distinct, addressable failure from a duplicate registration. Because the set
415
+ * is sorted before it is digested, the snapshot digest is independent of the input order.
416
+ */
417
+ export async function composeRegistrySnapshot(
418
+ descriptors: readonly CapabilityDescriptor[],
419
+ ): Promise<RegistrySnapshot> {
420
+ const validated = descriptors.map((descriptor) => parseCapabilityDescriptor(descriptor))
421
+ const byIdentity = new Map<string, CapabilityDescriptor>()
422
+ for (const descriptor of validated) {
423
+ const key = identityKey(descriptor)
424
+ const existing = byIdentity.get(key)
425
+ if (existing !== undefined) {
426
+ throw new RegistryError(
427
+ existing.schemaDigest === descriptor.schemaDigest ? "descriptor_collision" : "schema_drift",
428
+ )
429
+ }
430
+ byIdentity.set(key, descriptor)
431
+ }
432
+ const ordered = [...validated].sort(compareDescriptors)
433
+ const digest = await sha256HexOf(
434
+ canonicalJson({
435
+ version: REGISTRY_SNAPSHOT_VERSION,
436
+ descriptors: ordered.map(canonicalDescriptor),
437
+ }),
438
+ )
439
+ return Object.freeze({
440
+ version: REGISTRY_SNAPSHOT_VERSION,
441
+ digest,
442
+ descriptors: Object.freeze(ordered),
443
+ })
444
+ }