@nifrajs/core 2.10.0 → 2.12.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.
Files changed (180) hide show
  1. package/dist/assurance.d.ts +61 -3
  2. package/dist/assurance.d.ts.map +1 -1
  3. package/dist/assurance.js +152 -5
  4. package/dist/assurance.js.map +1 -1
  5. package/dist/capabilities.d.ts +9 -1
  6. package/dist/capabilities.d.ts.map +1 -1
  7. package/dist/capabilities.js +5 -1
  8. package/dist/capabilities.js.map +1 -1
  9. package/dist/diff.d.ts +2 -1
  10. package/dist/diff.d.ts.map +1 -1
  11. package/dist/diff.js +3 -1
  12. package/dist/diff.js.map +1 -1
  13. package/dist/errors.d.ts +1 -1
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js.map +1 -1
  16. package/dist/evidence.d.ts +89 -0
  17. package/dist/evidence.d.ts.map +1 -0
  18. package/dist/evidence.js +170 -0
  19. package/dist/evidence.js.map +1 -0
  20. package/dist/execution-policy.d.ts +23 -0
  21. package/dist/execution-policy.d.ts.map +1 -0
  22. package/dist/execution-policy.js +28 -0
  23. package/dist/execution-policy.js.map +1 -0
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/internal/route-assurance.d.ts +19 -0
  27. package/dist/internal/route-assurance.d.ts.map +1 -1
  28. package/dist/internal/route-assurance.js +38 -5
  29. package/dist/internal/route-assurance.js.map +1 -1
  30. package/dist/internal/route-execution.d.ts +18 -0
  31. package/dist/internal/route-execution.d.ts.map +1 -1
  32. package/dist/internal/route-execution.js +67 -1
  33. package/dist/internal/route-execution.js.map +1 -1
  34. package/dist/manifest.d.ts +5 -1
  35. package/dist/manifest.d.ts.map +1 -1
  36. package/dist/manifest.js +22 -4
  37. package/dist/manifest.js.map +1 -1
  38. package/dist/reflection.d.ts +5 -0
  39. package/dist/reflection.d.ts.map +1 -1
  40. package/dist/reflection.js +13 -4
  41. package/dist/reflection.js.map +1 -1
  42. package/dist/replay.d.ts +26 -0
  43. package/dist/replay.d.ts.map +1 -0
  44. package/dist/replay.js +63 -0
  45. package/dist/replay.js.map +1 -0
  46. package/dist/schema/standard.d.ts +2 -0
  47. package/dist/schema/standard.d.ts.map +1 -1
  48. package/dist/schema/standard.js +13 -0
  49. package/dist/schema/standard.js.map +1 -1
  50. package/dist/server/body.d.ts +49 -6
  51. package/dist/server/body.d.ts.map +1 -1
  52. package/dist/server/body.js +226 -8
  53. package/dist/server/body.js.map +1 -1
  54. package/dist/server/context.d.ts +20 -3
  55. package/dist/server/context.d.ts.map +1 -1
  56. package/dist/server/contract.d.ts +15 -3
  57. package/dist/server/contract.d.ts.map +1 -1
  58. package/dist/server/contract.js +22 -0
  59. package/dist/server/contract.js.map +1 -1
  60. package/dist/server/cookies.d.ts +26 -5
  61. package/dist/server/cookies.d.ts.map +1 -1
  62. package/dist/server/cookies.js +76 -9
  63. package/dist/server/cookies.js.map +1 -1
  64. package/dist/server/edge.d.ts.map +1 -1
  65. package/dist/server/edge.js +11 -0
  66. package/dist/server/edge.js.map +1 -1
  67. package/dist/server/header-case.d.ts +9 -0
  68. package/dist/server/header-case.d.ts.map +1 -0
  69. package/dist/server/header-case.js +54 -0
  70. package/dist/server/header-case.js.map +1 -0
  71. package/dist/server/headers.d.ts +8 -0
  72. package/dist/server/headers.d.ts.map +1 -0
  73. package/dist/server/headers.js +13 -0
  74. package/dist/server/headers.js.map +1 -0
  75. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  76. package/dist/server/node-outcome-hook.js +7 -11
  77. package/dist/server/node-outcome-hook.js.map +1 -1
  78. package/dist/server/node-outcome.d.ts +8 -1
  79. package/dist/server/node-outcome.d.ts.map +1 -1
  80. package/dist/server/node-outcome.js +10 -3
  81. package/dist/server/node-outcome.js.map +1 -1
  82. package/dist/server/plugin.d.ts +66 -7
  83. package/dist/server/plugin.d.ts.map +1 -1
  84. package/dist/server/plugin.js +14 -10
  85. package/dist/server/plugin.js.map +1 -1
  86. package/dist/server/proto-guard.d.ts +30 -0
  87. package/dist/server/proto-guard.d.ts.map +1 -0
  88. package/dist/server/proto-guard.js +116 -0
  89. package/dist/server/proto-guard.js.map +1 -0
  90. package/dist/server/registry.d.ts +6 -2
  91. package/dist/server/registry.d.ts.map +1 -1
  92. package/dist/server/request-context.d.ts +24 -6
  93. package/dist/server/request-context.d.ts.map +1 -1
  94. package/dist/server/request-context.js +157 -30
  95. package/dist/server/request-context.js.map +1 -1
  96. package/dist/server/respond.d.ts +2 -2
  97. package/dist/server/respond.d.ts.map +1 -1
  98. package/dist/server/respond.js +34 -8
  99. package/dist/server/respond.js.map +1 -1
  100. package/dist/server/response-contract-lane.d.ts +1 -1
  101. package/dist/server/response-contract-lane.d.ts.map +1 -1
  102. package/dist/server/response-contract-lane.js +45 -13
  103. package/dist/server/response-contract-lane.js.map +1 -1
  104. package/dist/server/response-hooks.d.ts +10 -0
  105. package/dist/server/response-hooks.d.ts.map +1 -0
  106. package/dist/server/response-hooks.js +52 -0
  107. package/dist/server/response-hooks.js.map +1 -0
  108. package/dist/server/runtime-core.d.ts +12 -0
  109. package/dist/server/runtime-core.d.ts.map +1 -1
  110. package/dist/server/runtime-core.js +8 -0
  111. package/dist/server/runtime-core.js.map +1 -1
  112. package/dist/server/server-types.d.ts +60 -6
  113. package/dist/server/server-types.d.ts.map +1 -1
  114. package/dist/server/server.d.ts +80 -23
  115. package/dist/server/server.d.ts.map +1 -1
  116. package/dist/server/server.js +424 -213
  117. package/dist/server/server.js.map +1 -1
  118. package/dist/server/static-headers.d.ts +14 -3
  119. package/dist/server/static-headers.d.ts.map +1 -1
  120. package/dist/server/static-headers.js +28 -6
  121. package/dist/server/static-headers.js.map +1 -1
  122. package/dist/server/transport-lane.d.ts +8 -0
  123. package/dist/server/transport-lane.d.ts.map +1 -1
  124. package/dist/server/transport-lane.js +12 -8
  125. package/dist/server/transport-lane.js.map +1 -1
  126. package/dist/server/websocket.d.ts +3 -0
  127. package/dist/server/websocket.d.ts.map +1 -1
  128. package/dist/server/websocket.js +14 -0
  129. package/dist/server/websocket.js.map +1 -1
  130. package/dist/server.d.ts +2 -2
  131. package/dist/server.d.ts.map +1 -1
  132. package/dist/server.js +2 -2
  133. package/dist/server.js.map +1 -1
  134. package/dist/tool-catalog.d.ts +21 -0
  135. package/dist/tool-catalog.d.ts.map +1 -0
  136. package/dist/tool-catalog.js +16 -0
  137. package/dist/tool-catalog.js.map +1 -0
  138. package/dist/tool-contract.d.ts +238 -0
  139. package/dist/tool-contract.d.ts.map +1 -0
  140. package/dist/tool-contract.js +569 -0
  141. package/dist/tool-contract.js.map +1 -0
  142. package/package.json +26 -1
  143. package/src/assurance.ts +236 -6
  144. package/src/capabilities.ts +14 -1
  145. package/src/diff.ts +5 -2
  146. package/src/errors.ts +1 -0
  147. package/src/evidence.ts +319 -0
  148. package/src/execution-policy.ts +57 -0
  149. package/src/index.ts +1 -1
  150. package/src/internal/route-assurance.ts +67 -4
  151. package/src/internal/route-execution.ts +276 -1
  152. package/src/manifest.ts +27 -5
  153. package/src/reflection.ts +24 -4
  154. package/src/replay.ts +96 -0
  155. package/src/schema/standard.ts +16 -0
  156. package/src/server/body.ts +275 -7
  157. package/src/server/context.ts +23 -3
  158. package/src/server/contract.ts +43 -3
  159. package/src/server/cookies.ts +80 -10
  160. package/src/server/edge.ts +10 -0
  161. package/src/server/header-case.ts +54 -0
  162. package/src/server/headers.ts +11 -0
  163. package/src/server/node-outcome-hook.ts +6 -11
  164. package/src/server/node-outcome.ts +10 -2
  165. package/src/server/plugin.ts +87 -8
  166. package/src/server/proto-guard.ts +118 -0
  167. package/src/server/registry.ts +6 -2
  168. package/src/server/request-context.ts +242 -26
  169. package/src/server/respond.ts +35 -4
  170. package/src/server/response-contract-lane.ts +6 -17
  171. package/src/server/response-hooks.ts +51 -0
  172. package/src/server/runtime-core.ts +14 -0
  173. package/src/server/server-types.ts +66 -6
  174. package/src/server/server.ts +667 -287
  175. package/src/server/static-headers.ts +27 -3
  176. package/src/server/transport-lane.ts +19 -8
  177. package/src/server/websocket.ts +18 -2
  178. package/src/server.ts +7 -0
  179. package/src/tool-catalog.ts +40 -0
  180. package/src/tool-contract.ts +896 -0
@@ -0,0 +1,896 @@
1
+ /**
2
+ * Typed tool contracts.
3
+ *
4
+ * A tool is an owned effect seam for an agent. The contract carries the data shape, authority,
5
+ * approval, idempotency, dimensionless cost, and sensitivity metadata that every adapter needs.
6
+ * Adapters call {@link executeTool}; they do not reimplement admission.
7
+ *
8
+ * This module is deliberately token-only in its evidence and idempotency stores. It never persists
9
+ * tool arguments, results, request bodies, or business values.
10
+ */
11
+
12
+ import type { RequestBudget } from "./budget.ts"
13
+ import {
14
+ defineExecutionPolicy,
15
+ type ExecutionPolicy,
16
+ type ExecutionPolicyAdapter,
17
+ } from "./execution-policy.ts"
18
+ import {
19
+ type IdempotencyScope,
20
+ validIdempotencyKey,
21
+ validIdempotencyNamespace,
22
+ } from "./idempotency.ts"
23
+ import { validCapabilityId } from "./internal/capability-runtime.ts"
24
+ import {
25
+ createRequestLedger,
26
+ type EffectCost,
27
+ normalizeEffectMetadata,
28
+ type RequestLedger,
29
+ type SealedEffectLedger,
30
+ } from "./ledger.ts"
31
+ import { reflectSchema } from "./reflection.ts"
32
+ import {
33
+ type InferOutput,
34
+ type StandardIssue,
35
+ type StandardSchemaV1,
36
+ validateStandard,
37
+ } from "./schema/standard.ts"
38
+ import { assertByteLimit, readBoundedBytes } from "./server/body.ts"
39
+ import { type ProtoPoisoning, parseJsonGuarded } from "./server/proto-guard.ts"
40
+
41
+ const TOOL_NAME = /^[a-z][a-z0-9._-]{0,63}$/
42
+ const MAX_EVIDENCE = 64
43
+ const DEFAULT_NAMESPACE = "tool"
44
+ const DEFAULT_CAPABILITY = "tool.execute"
45
+
46
+ export type ToolSensitivity = "public" | "internal" | "sensitive" | "secret"
47
+
48
+ export type ToolApprovalPolicy =
49
+ | { readonly kind: "none" }
50
+ | { readonly kind: "required" }
51
+ | { readonly kind: "threshold"; readonly level: number }
52
+
53
+ export type ToolApproval =
54
+ | { readonly granted: true; readonly level?: number }
55
+ | { readonly granted: false; readonly reason?: string }
56
+
57
+ export interface ToolAnnotations {
58
+ readonly title?: string
59
+ readonly readOnlyHint?: boolean
60
+ readonly destructiveHint?: boolean
61
+ readonly idempotentHint?: boolean
62
+ readonly openWorldHint?: boolean
63
+ }
64
+
65
+ export interface ToolIdempotencyPolicy<Input = unknown> {
66
+ readonly scope: Exclude<IdempotencyScope, "none">
67
+ /** Return an opaque bounded key. The key must not contain a request body or secret. */
68
+ readonly key: (input: Input) => string | PromiseLike<string>
69
+ }
70
+
71
+ export interface ToolContractOptions<
72
+ Input,
73
+ Output,
74
+ InputSchema extends StandardSchemaV1,
75
+ OutputSchema extends StandardSchemaV1,
76
+ > {
77
+ readonly name: string
78
+ readonly description: string
79
+ readonly input: InputSchema
80
+ readonly output: OutputSchema
81
+ readonly capability?: string
82
+ readonly approval?: ToolApprovalPolicy
83
+ readonly idempotency?: ToolIdempotencyPolicy<Input>
84
+ /** Dimensionless counters such as `{ calls: 1, ms: 20 }`; never prices. */
85
+ readonly cost?: EffectCost
86
+ readonly sensitivity?: ToolSensitivity
87
+ readonly annotations?: ToolAnnotations
88
+ readonly policy?: ExecutionPolicy
89
+ readonly execute: (input: Input, context: ToolExecutionContext) => Output | PromiseLike<Output>
90
+ }
91
+
92
+ export interface ToolContract<Input = unknown, Output = unknown> {
93
+ readonly name: string
94
+ readonly description: string
95
+ readonly input: StandardSchemaV1<unknown, Input>
96
+ readonly output: StandardSchemaV1<unknown, Output>
97
+ readonly capability: string
98
+ readonly approval: ToolApprovalPolicy
99
+ readonly idempotency?: ToolIdempotencyPolicy<Input>
100
+ readonly cost?: EffectCost
101
+ readonly sensitivity: ToolSensitivity
102
+ readonly annotations: ToolAnnotations
103
+ readonly policy?: ExecutionPolicy
104
+ readonly execute: (input: Input, context: ToolExecutionContext) => Output | PromiseLike<Output>
105
+ }
106
+
107
+ export interface ToolExecutionContext {
108
+ readonly effectId: string
109
+ readonly signal: AbortSignal
110
+ /** Wall-clock budget shared with the parent request, when an agent supplies one. */
111
+ readonly deadline?: RequestBudget
112
+ readonly dryRun: boolean
113
+ readonly policy?: ExecutionPolicy
114
+ }
115
+
116
+ export interface ToolBudget {
117
+ readonly consume: (cost: EffectCost | undefined) => boolean
118
+ readonly snapshot: () => EffectCost
119
+ }
120
+
121
+ export interface CreateToolBudgetOptions {
122
+ readonly limits: EffectCost
123
+ readonly initial?: EffectCost
124
+ }
125
+
126
+ export function createToolBudget(options: CreateToolBudgetOptions): ToolBudget {
127
+ const limits = normalizeCost(options.limits, "tool budget limits")
128
+ const spent = normalizeCost(options.initial ?? {}, "tool budget initial")
129
+ for (const [axis, value] of Object.entries(spent)) {
130
+ if ((limits[axis] ?? 0) < value) {
131
+ throw new RangeError(`tool budget: initial ${axis} exceeds its limit`)
132
+ }
133
+ }
134
+ return {
135
+ consume(cost) {
136
+ const next = normalizeCost(cost ?? {}, "tool cost")
137
+ for (const [axis, value] of Object.entries(next)) {
138
+ if ((limits[axis] ?? 0) < (spent[axis] ?? 0) + value) return false
139
+ }
140
+ for (const [axis, value] of Object.entries(next)) spent[axis] = (spent[axis] ?? 0) + value
141
+ return true
142
+ },
143
+ snapshot() {
144
+ return Object.freeze({ ...spent })
145
+ },
146
+ }
147
+ }
148
+
149
+ export interface ToolIdempotencyBeginInput {
150
+ readonly namespace: string
151
+ readonly key: string
152
+ }
153
+
154
+ export type ToolIdempotencyBeginResult =
155
+ | { readonly state: "new"; readonly reservation: string }
156
+ | { readonly state: "duplicate" }
157
+ | { readonly state: "in-flight" }
158
+ | { readonly state: "capacity" }
159
+
160
+ export interface ToolIdempotencyStore {
161
+ readonly durability?: "memory" | "durable"
162
+ begin(
163
+ input: ToolIdempotencyBeginInput,
164
+ ): ToolIdempotencyBeginResult | PromiseLike<ToolIdempotencyBeginResult>
165
+ complete(input: {
166
+ readonly namespace: string
167
+ readonly key: string
168
+ readonly reservation: string
169
+ }): boolean | PromiseLike<boolean>
170
+ abandon(input: {
171
+ readonly namespace: string
172
+ readonly key: string
173
+ readonly reservation: string
174
+ }): boolean | PromiseLike<boolean>
175
+ }
176
+
177
+ interface ToolIdempotencyEntry {
178
+ readonly reservation: string
179
+ readonly expiresAt: number
180
+ completed: boolean
181
+ }
182
+
183
+ export interface MemoryToolIdempotencyStoreOptions {
184
+ readonly maxEntries?: number
185
+ readonly ttlMs?: number
186
+ readonly now?: () => number
187
+ }
188
+
189
+ export class MemoryToolIdempotencyStore implements ToolIdempotencyStore {
190
+ readonly durability = "memory" as const
191
+ private readonly entries = new Map<string, ToolIdempotencyEntry>()
192
+ private readonly maxEntries: number
193
+ private readonly ttlMs: number
194
+ private readonly now: () => number
195
+
196
+ constructor(options: MemoryToolIdempotencyStoreOptions = {}) {
197
+ this.maxEntries = options.maxEntries ?? 10_000
198
+ this.ttlMs = options.ttlMs ?? 86_400_000
199
+ this.now = options.now ?? Date.now
200
+ if (!Number.isSafeInteger(this.maxEntries) || this.maxEntries < 1) {
201
+ throw new RangeError("tool idempotency: maxEntries must be a positive safe integer")
202
+ }
203
+ if (!Number.isSafeInteger(this.ttlMs) || this.ttlMs < 1) {
204
+ throw new RangeError("tool idempotency: ttlMs must be a positive safe integer")
205
+ }
206
+ }
207
+
208
+ begin(input: ToolIdempotencyBeginInput): ToolIdempotencyBeginResult {
209
+ validateIdempotencyInput(input)
210
+ const key = `${input.namespace.length}:${input.namespace}${input.key}`
211
+ const existing = this.entries.get(key)
212
+ const now = this.now()
213
+ if (existing !== undefined && existing.expiresAt > now) {
214
+ return { state: existing.completed ? "duplicate" : "in-flight" }
215
+ }
216
+ if (existing !== undefined) this.entries.delete(key)
217
+ this.sweep()
218
+ if (this.entries.size >= this.maxEntries) return { state: "capacity" }
219
+ this.entries.set(key, {
220
+ reservation: crypto.randomUUID(),
221
+ expiresAt: now + this.ttlMs,
222
+ completed: false,
223
+ })
224
+ return { state: "new", reservation: this.entries.get(key)!.reservation }
225
+ }
226
+
227
+ complete(input: {
228
+ readonly namespace: string
229
+ readonly key: string
230
+ readonly reservation: string
231
+ }): boolean {
232
+ validateIdempotencyInput(input)
233
+ const entry = this.entries.get(`${input.namespace.length}:${input.namespace}${input.key}`)
234
+ if (
235
+ entry === undefined ||
236
+ entry.reservation !== input.reservation ||
237
+ entry.expiresAt <= this.now()
238
+ )
239
+ return false
240
+ entry.completed = true
241
+ return true
242
+ }
243
+
244
+ abandon(input: {
245
+ readonly namespace: string
246
+ readonly key: string
247
+ readonly reservation: string
248
+ }): boolean {
249
+ validateIdempotencyInput(input)
250
+ const key = `${input.namespace.length}:${input.namespace}${input.key}`
251
+ const entry = this.entries.get(key)
252
+ if (entry === undefined || entry.reservation !== input.reservation || entry.completed)
253
+ return false
254
+ this.entries.delete(key)
255
+ return true
256
+ }
257
+
258
+ sweep(): void {
259
+ const now = this.now()
260
+ for (const [key, entry] of this.entries) if (entry.expiresAt <= now) this.entries.delete(key)
261
+ }
262
+ }
263
+
264
+ export type ToolEvidenceStage =
265
+ | "input"
266
+ | "capability"
267
+ | "policy"
268
+ | "approval"
269
+ | "idempotency"
270
+ | "budget"
271
+ | "execution"
272
+ | "output"
273
+
274
+ export type ToolEvidenceOutcome =
275
+ | "passed"
276
+ | "denied"
277
+ | "failed"
278
+ | "skipped"
279
+ | "committed"
280
+ | "dry-run"
281
+
282
+ export interface ToolEvidence {
283
+ readonly seq: number
284
+ readonly stage: ToolEvidenceStage
285
+ readonly outcome: ToolEvidenceOutcome
286
+ readonly code?: string
287
+ }
288
+
289
+ export interface ToolError {
290
+ readonly code:
291
+ | "input_invalid"
292
+ | "capability_denied"
293
+ | "execution_policy_unsatisfied"
294
+ | "approval_required"
295
+ | "approval_denied"
296
+ | "idempotency_store_missing"
297
+ | "idempotency_durability"
298
+ | "idempotency_duplicate"
299
+ | "idempotency_in_flight"
300
+ | "idempotency_capacity"
301
+ | "budget_exceeded"
302
+ | "cancelled"
303
+ | "execution_failed"
304
+ | "output_invalid"
305
+ | "ledger_failed"
306
+ readonly stage: ToolEvidenceStage
307
+ readonly issues?: readonly StandardIssue[]
308
+ }
309
+
310
+ export type ToolCallResult<Output> =
311
+ | {
312
+ readonly ok: true
313
+ readonly output?: Output
314
+ readonly dryRun: boolean
315
+ readonly evidence: readonly ToolEvidence[]
316
+ readonly ledger: SealedEffectLedger
317
+ }
318
+ | {
319
+ readonly ok: false
320
+ readonly dryRun: boolean
321
+ readonly output?: undefined
322
+ readonly error: ToolError
323
+ readonly evidence: readonly ToolEvidence[]
324
+ readonly ledger: SealedEffectLedger
325
+ }
326
+
327
+ export interface ToolCallOptions {
328
+ readonly effectId?: string
329
+ readonly clock?: () => number
330
+ readonly signal?: AbortSignal
331
+ /** Wall-clock budget shared with the parent request, distinct from the cost `budget`. */
332
+ readonly deadline?: RequestBudget
333
+ readonly capabilities?: readonly string[]
334
+ readonly approval?: ToolApproval
335
+ readonly budget?: ToolBudget
336
+ readonly idempotency?: ToolIdempotencyStore
337
+ readonly namespace?: string
338
+ readonly dryRun?: boolean
339
+ readonly ledger?: RequestLedger
340
+ readonly executionPolicy?: ExecutionPolicyAdapter
341
+ }
342
+
343
+ export function defineTool<
344
+ InputSchema extends StandardSchemaV1,
345
+ OutputSchema extends StandardSchemaV1,
346
+ >(
347
+ options: ToolContractOptions<
348
+ InferOutput<InputSchema>,
349
+ InferOutput<OutputSchema>,
350
+ InputSchema,
351
+ OutputSchema
352
+ >,
353
+ ): ToolContract<InferOutput<InputSchema>, InferOutput<OutputSchema>> {
354
+ if (typeof options !== "object" || options === null)
355
+ throw new TypeError("tool contract: options must be an object")
356
+ if (!TOOL_NAME.test(options.name))
357
+ throw new TypeError(`tool contract: invalid name ${JSON.stringify(options.name)}`)
358
+ if (typeof options.description !== "string" || options.description.trim() === "") {
359
+ throw new TypeError("tool contract: description must not be empty")
360
+ }
361
+ if (!isStandardSchema(options.input) || !isStandardSchema(options.output)) {
362
+ throw new TypeError("tool contract: input and output must implement Standard Schema")
363
+ }
364
+ if (options.capability !== undefined && !validCapabilityId(options.capability)) {
365
+ throw new TypeError(`tool contract: invalid capability ${JSON.stringify(options.capability)}`)
366
+ }
367
+ const approval = validateApprovalPolicy(options.approval ?? { kind: "none" })
368
+ const cost =
369
+ options.cost === undefined ? undefined : normalizeEffectMetadata({ cost: options.cost }).cost
370
+ const sensitivity = options.sensitivity ?? "internal"
371
+ if (!isSensitivity(sensitivity)) throw new TypeError("tool contract: invalid sensitivity")
372
+ const policy = options.policy === undefined ? undefined : defineExecutionPolicy(options.policy)
373
+ if (options.idempotency !== undefined) validateIdempotencyPolicy(options.idempotency)
374
+ if (typeof options.execute !== "function")
375
+ throw new TypeError("tool contract: execute must be a function")
376
+ return Object.freeze({
377
+ name: options.name,
378
+ description: options.description.trim(),
379
+ input: options.input,
380
+ output: options.output,
381
+ capability: options.capability ?? `${DEFAULT_CAPABILITY}.${options.name}`,
382
+ approval,
383
+ ...(options.idempotency === undefined ? {} : { idempotency: options.idempotency }),
384
+ ...(cost === undefined ? {} : { cost }),
385
+ sensitivity,
386
+ annotations: Object.freeze({ ...(options.annotations ?? {}) }),
387
+ ...(policy === undefined ? {} : { policy }),
388
+ execute: options.execute,
389
+ })
390
+ }
391
+
392
+ export async function executeTool<Input, Output>(
393
+ tool: ToolContract<Input, Output>,
394
+ input: unknown,
395
+ options: ToolCallOptions = {},
396
+ ): Promise<ToolCallResult<Output>> {
397
+ const signal = options.signal ?? new AbortController().signal
398
+ const dryRun = options.dryRun === true
399
+ const ledger =
400
+ options.ledger ??
401
+ createRequestLedger({
402
+ method: "TOOL",
403
+ path: `/${tool.name}`,
404
+ declared: [tool.capability],
405
+ chain: true,
406
+ ...(options.clock === undefined ? {} : { clock: options.clock }),
407
+ })
408
+ const evidence: ToolEvidence[] = []
409
+ const effectId = options.effectId ?? crypto.randomUUID()
410
+ let reservation:
411
+ | { readonly namespace: string; readonly key: string; readonly value: string }
412
+ | undefined
413
+ const record = (stage: ToolEvidenceStage, outcome: ToolEvidenceOutcome, code?: string): void => {
414
+ if (evidence.length >= MAX_EVIDENCE) return
415
+ evidence.push({ seq: evidence.length, stage, outcome, ...(code === undefined ? {} : { code }) })
416
+ }
417
+ const appendIntent = (): void => {
418
+ ledger.append({
419
+ capability: tool.capability,
420
+ effectId,
421
+ phase: "intent",
422
+ target: `tool:${tool.name}`,
423
+ ...(tool.cost === undefined ? {} : { cost: tool.cost }),
424
+ })
425
+ }
426
+ type ToolCallOutcome =
427
+ | { readonly ok: true; readonly dryRun: boolean; readonly output?: Output }
428
+ | { readonly ok: false; readonly dryRun: boolean; readonly error: ToolError }
429
+ const finish = async (result: ToolCallOutcome): Promise<ToolCallResult<Output>> => {
430
+ try {
431
+ if (result.ok) {
432
+ ledger.append({
433
+ capability: tool.capability,
434
+ effectId,
435
+ phase: "committed",
436
+ target: `tool:${tool.name}`,
437
+ ...(tool.cost === undefined ? {} : { cost: tool.cost }),
438
+ })
439
+ } else {
440
+ ledger.append({
441
+ capability: tool.capability,
442
+ effectId,
443
+ phase: "failed",
444
+ target: `tool:${tool.name}`,
445
+ ...(tool.cost === undefined ? {} : { cost: tool.cost }),
446
+ error: { code: result.error.code },
447
+ })
448
+ }
449
+ const sealed = await ledger.seal()
450
+ return { ...result, evidence: Object.freeze([...evidence]), ledger: sealed }
451
+ } catch {
452
+ const fallback: ToolCallResult<Output> = {
453
+ ok: false,
454
+ dryRun,
455
+ error: { code: "ledger_failed", stage: "execution" },
456
+ evidence: Object.freeze([
457
+ ...evidence,
458
+ { seq: evidence.length, stage: "execution", outcome: "failed", code: "ledger_failed" },
459
+ ]),
460
+ ledger: await createRequestLedger({
461
+ method: "TOOL",
462
+ path: `/${tool.name}`,
463
+ declared: [tool.capability],
464
+ ...(options.clock === undefined ? {} : { clock: options.clock }),
465
+ }).seal(),
466
+ }
467
+ return fallback
468
+ }
469
+ }
470
+
471
+ try {
472
+ appendIntent()
473
+ const validatedInput = await validateStandard(tool.input, input)
474
+ if (!validatedInput.ok) {
475
+ record("input", "denied", "input_invalid")
476
+ return finish({
477
+ ok: false,
478
+ dryRun,
479
+ error: { code: "input_invalid", stage: "input", issues: validatedInput.issues },
480
+ })
481
+ }
482
+ record("input", "passed")
483
+
484
+ if (signal.aborted) {
485
+ record("capability", "denied", "cancelled")
486
+ return finish({ ok: false, dryRun, error: { code: "cancelled", stage: "capability" } })
487
+ }
488
+ if (!hasCapability(options.capabilities, tool.capability)) {
489
+ record("capability", "denied", "capability_denied")
490
+ return finish({
491
+ ok: false,
492
+ dryRun,
493
+ error: { code: "capability_denied", stage: "capability" },
494
+ })
495
+ }
496
+ record("capability", "passed")
497
+
498
+ if (tool.policy !== undefined) {
499
+ if (
500
+ !tool.policy.capabilityCeiling.includes(tool.capability) ||
501
+ options.executionPolicy === undefined ||
502
+ !(await options.executionPolicy.canSatisfy(tool.policy))
503
+ ) {
504
+ record("policy", "denied", "execution_policy_unsatisfied")
505
+ return finish({
506
+ ok: false,
507
+ dryRun,
508
+ error: { code: "execution_policy_unsatisfied", stage: "policy" },
509
+ })
510
+ }
511
+ record("policy", "passed")
512
+ }
513
+
514
+ const approvalError = checkApproval(tool.approval, options.approval)
515
+ if (approvalError !== undefined) {
516
+ record("approval", "denied", approvalError)
517
+ return finish({ ok: false, dryRun, error: { code: approvalError, stage: "approval" } })
518
+ }
519
+ record("approval", tool.approval.kind === "none" ? "skipped" : "passed")
520
+
521
+ if (tool.idempotency !== undefined) {
522
+ const namespace = options.namespace ?? DEFAULT_NAMESPACE
523
+ if (!validIdempotencyNamespace(namespace)) {
524
+ record("idempotency", "denied", "idempotency_store_missing")
525
+ return finish({
526
+ ok: false,
527
+ dryRun,
528
+ error: { code: "idempotency_store_missing", stage: "idempotency" },
529
+ })
530
+ }
531
+ if (options.idempotency === undefined) {
532
+ record("idempotency", "denied", "idempotency_store_missing")
533
+ return finish({
534
+ ok: false,
535
+ dryRun,
536
+ error: { code: "idempotency_store_missing", stage: "idempotency" },
537
+ })
538
+ }
539
+ if (tool.idempotency.scope === "durable" && options.idempotency.durability !== "durable") {
540
+ record("idempotency", "denied", "idempotency_durability")
541
+ return finish({
542
+ ok: false,
543
+ dryRun,
544
+ error: { code: "idempotency_durability", stage: "idempotency" },
545
+ })
546
+ }
547
+ const key = await tool.idempotency.key(validatedInput.value)
548
+ if (!validIdempotencyKey(key)) {
549
+ record("idempotency", "denied", "idempotency_store_missing")
550
+ return finish({
551
+ ok: false,
552
+ dryRun,
553
+ error: { code: "idempotency_store_missing", stage: "idempotency" },
554
+ })
555
+ }
556
+ const began = await options.idempotency.begin({ namespace, key })
557
+ if (began.state !== "new") {
558
+ const code =
559
+ began.state === "duplicate"
560
+ ? "idempotency_duplicate"
561
+ : began.state === "in-flight"
562
+ ? "idempotency_in_flight"
563
+ : "idempotency_capacity"
564
+ record("idempotency", "denied", code)
565
+ return finish({ ok: false, dryRun, error: { code, stage: "idempotency" } })
566
+ }
567
+ reservation = { namespace, key, value: began.reservation }
568
+ }
569
+ record("idempotency", tool.idempotency === undefined ? "skipped" : "passed")
570
+
571
+ if (options.budget !== undefined && !options.budget.consume(tool.cost)) {
572
+ if (reservation !== undefined)
573
+ await options.idempotency?.abandon(reservationInput(reservation))
574
+ record("budget", "denied", "budget_exceeded")
575
+ return finish({ ok: false, dryRun, error: { code: "budget_exceeded", stage: "budget" } })
576
+ }
577
+ record("budget", tool.cost === undefined ? "skipped" : "passed")
578
+
579
+ if (signal.aborted) {
580
+ if (reservation !== undefined)
581
+ await options.idempotency?.abandon(reservationInput(reservation))
582
+ record("execution", "denied", "cancelled")
583
+ return finish({ ok: false, dryRun, error: { code: "cancelled", stage: "execution" } })
584
+ }
585
+ if (dryRun) {
586
+ if (reservation !== undefined)
587
+ await options.idempotency?.abandon(reservationInput(reservation))
588
+ record("execution", "dry-run")
589
+ return finish({ ok: true, dryRun })
590
+ }
591
+ const output = await tool.execute(validatedInput.value, {
592
+ effectId,
593
+ signal,
594
+ ...(options.deadline === undefined ? {} : { deadline: options.deadline }),
595
+ dryRun,
596
+ ...(tool.policy === undefined ? {} : { policy: tool.policy }),
597
+ })
598
+ record("execution", "committed")
599
+ const validatedOutput = await validateStandard(tool.output, output)
600
+ if (!validatedOutput.ok) {
601
+ // The executor has already run. Keep the reservation completed even when its return shape is
602
+ // invalid, so a caller cannot retry and duplicate a side effect that may have committed.
603
+ if (reservation !== undefined) {
604
+ const completed = await options.idempotency?.complete(reservationInput(reservation))
605
+ if (completed !== true) {
606
+ record("idempotency", "failed", "idempotency_capacity")
607
+ return finish({
608
+ ok: false,
609
+ dryRun,
610
+ error: { code: "idempotency_capacity", stage: "idempotency" },
611
+ })
612
+ }
613
+ }
614
+ record("output", "failed", "output_invalid")
615
+ return finish({
616
+ ok: false,
617
+ dryRun,
618
+ error: { code: "output_invalid", stage: "output", issues: validatedOutput.issues },
619
+ })
620
+ }
621
+ record("output", "passed")
622
+ if (reservation !== undefined) {
623
+ const completed = await options.idempotency?.complete(reservationInput(reservation))
624
+ if (completed !== true) {
625
+ record("idempotency", "failed", "idempotency_capacity")
626
+ return finish({
627
+ ok: false,
628
+ dryRun,
629
+ error: { code: "idempotency_capacity", stage: "idempotency" },
630
+ })
631
+ }
632
+ }
633
+ return finish({ ok: true, dryRun, output: validatedOutput.value })
634
+ } catch (error) {
635
+ if (reservation !== undefined) await options.idempotency?.abandon(reservationInput(reservation))
636
+ const code = signal.aborted || isAbortError(error) ? "cancelled" : "execution_failed"
637
+ record("execution", "failed", code)
638
+ return finish({ ok: false, dryRun, error: { code, stage: "execution" } })
639
+ }
640
+ }
641
+
642
+ export interface ToolHttpOptions extends Omit<ToolCallOptions, "signal" | "ledger"> {
643
+ readonly method?: string
644
+ /** Prototype-poisoning policy for the JSON request body - mirrors the server option (this
645
+ * handler is standalone, so it carries its own). Default `"reject"`. */
646
+ readonly protoPoisoning?: ProtoPoisoning
647
+ /** Transport body cap in bytes for the JSON request body. Default {@link DEFAULT_TOOL_MAX_BYTES}
648
+ * (1 MiB). `"unlimited"` removes the cap and requires a written {@link maxBytesReason}: the
649
+ * mounting platform then owns the limit, and the exemption is reviewable rather than implicit. */
650
+ readonly maxBytes?: number | "unlimited"
651
+ /** Why this handler runs uncapped. Required with `maxBytes: "unlimited"`, rejected without it. */
652
+ readonly maxBytesReason?: string
653
+ }
654
+
655
+ /** Default transport body cap for {@link createToolHttpHandler}: 1 MiB. */
656
+ export const DEFAULT_TOOL_MAX_BYTES = 1_048_576
657
+
658
+ /** Validate the handler's body cap at construction, so a misdeclared limit is a startup error
659
+ * rather than a request-time surprise. `undefined` means the caller opted out with a reason. */
660
+ function resolveToolMaxBytes(name: string, options: ToolHttpOptions): number | undefined {
661
+ const { maxBytes, maxBytesReason } = options
662
+ if (maxBytes === "unlimited") {
663
+ if (typeof maxBytesReason !== "string" || maxBytesReason.trim().length === 0) {
664
+ throw new Error(`tool ${name}: maxBytes: "unlimited" requires a non-empty maxBytesReason`)
665
+ }
666
+ return undefined
667
+ }
668
+ if (maxBytesReason !== undefined) {
669
+ throw new Error(`tool ${name}: maxBytesReason is only valid with maxBytes: "unlimited"`)
670
+ }
671
+ if (maxBytes === undefined) return DEFAULT_TOOL_MAX_BYTES
672
+ assertByteLimit(maxBytes, "tool maxBytes")
673
+ return maxBytes
674
+ }
675
+
676
+ /**
677
+ * Mount one contract behind a Web-standard handler. The handler accepts one JSON request body.
678
+ * A body carrying a poisoned key (own `__proto__`, or `constructor.prototype`) is rejected with
679
+ * the same `input_invalid` result as malformed JSON. The body is capped at
680
+ * {@link ToolHttpOptions.maxBytes} (1 MiB by default); over-cap answers a flat `413` and a
681
+ * malformed `Content-Length` a flat `400`, before any parse.
682
+ */
683
+ export function createToolHttpHandler<Input, Output>(
684
+ tool: ToolContract<Input, Output>,
685
+ options: ToolHttpOptions = {},
686
+ ): (request: Request) => Promise<Response> {
687
+ const method = (options.method ?? "POST").toUpperCase()
688
+ const protoPoisoning = options.protoPoisoning ?? "reject"
689
+ const maxBytes = resolveToolMaxBytes(tool.name, options)
690
+ const decoder = new TextDecoder()
691
+ return async (request) => {
692
+ if (request.method.toUpperCase() !== method) {
693
+ return new Response(null, { status: 405, headers: { allow: method } })
694
+ }
695
+ let text: string
696
+ if (maxBytes === undefined) {
697
+ text = await request.text()
698
+ } else {
699
+ // The cap is enforced BEFORE the parse, so an oversized payload is never materialized as a
700
+ // JS value. A flat status carries no tool ledger: nothing was admitted to produce one.
701
+ const read = await readBoundedBytes(request, maxBytes)
702
+ if (!read.ok) return new Response(null, { status: read.status })
703
+ text = decoder.decode(read.bytes)
704
+ }
705
+ let input: unknown
706
+ try {
707
+ input =
708
+ protoPoisoning === "ignore" ? JSON.parse(text) : parseJsonGuarded(text, protoPoisoning)
709
+ } catch {
710
+ return toolHttpResult({
711
+ ok: false,
712
+ dryRun: options.dryRun === true,
713
+ error: { code: "input_invalid", stage: "input" },
714
+ evidence: [],
715
+ ledger: await createRequestLedger({
716
+ method: "TOOL",
717
+ path: `/${tool.name}`,
718
+ declared: [tool.capability],
719
+ }).seal(),
720
+ })
721
+ }
722
+ const result = await executeTool(tool, input, { ...options, signal: request.signal })
723
+ return toolHttpResult(result)
724
+ }
725
+ }
726
+
727
+ export function toolHttpResult<Output>(result: ToolCallResult<Output>): Response {
728
+ const body = JSON.stringify(
729
+ result.ok
730
+ ? { ok: true, output: result.output, dryRun: result.dryRun, evidence: result.evidence }
731
+ : { ok: false, error: result.error, dryRun: result.dryRun, evidence: result.evidence },
732
+ )
733
+ const status = result.ok
734
+ ? 200
735
+ : result.error.code === "input_invalid"
736
+ ? 422
737
+ : result.error.code === "approval_required" ||
738
+ result.error.code === "approval_denied" ||
739
+ result.error.code === "capability_denied"
740
+ ? 403
741
+ : result.error.code === "budget_exceeded"
742
+ ? 429
743
+ : 500
744
+ return new Response(body, { status, headers: { "content-type": "application/json" } })
745
+ }
746
+
747
+ export function toolInputJsonSchema<Input, Output>(
748
+ tool: ToolContract<Input, Output>,
749
+ ): Record<string, unknown> {
750
+ const reflected = reflectSchema(tool.input).jsonSchema
751
+ if (
752
+ reflected !== undefined &&
753
+ typeof reflected === "object" &&
754
+ reflected !== null &&
755
+ !Array.isArray(reflected)
756
+ )
757
+ return { ...reflected }
758
+ return { type: "object", properties: {}, additionalProperties: false }
759
+ }
760
+
761
+ export type ToolAdapterResult<Output = unknown> =
762
+ | {
763
+ readonly ok: true
764
+ readonly output?: Output
765
+ readonly dryRun: boolean
766
+ readonly evidence: readonly ToolEvidence[]
767
+ }
768
+ | {
769
+ readonly ok: false
770
+ readonly dryRun: boolean
771
+ readonly error: ToolError
772
+ readonly evidence: readonly ToolEvidence[]
773
+ }
774
+
775
+ export interface ToolAdapter {
776
+ readonly name: string
777
+ call(input: unknown, options?: ToolCallOptions): Promise<ToolAdapterResult>
778
+ }
779
+
780
+ export interface ToolConformanceResult {
781
+ readonly adapter: string
782
+ readonly checks: readonly string[]
783
+ }
784
+
785
+ /** Shared conformance assertions for adapters. It intentionally checks only public, token-only behavior. */
786
+ export async function runToolContractConformance(
787
+ adapter: ToolAdapter,
788
+ options: {
789
+ readonly input: unknown
790
+ readonly capability: string
791
+ readonly approval: ToolApproval
792
+ readonly dryRun: ToolCallOptions
793
+ },
794
+ ): Promise<ToolConformanceResult> {
795
+ if (typeof adapter.call !== "function")
796
+ throw new TypeError("tool conformance: adapter must be callable")
797
+ const checks: string[] = []
798
+ const denied = await adapter.call(options.input, { capabilities: [] })
799
+ if (denied.ok || denied.error.code !== "capability_denied")
800
+ throw new Error("tool conformance: capability denial failed")
801
+ checks.push("capability denial")
802
+ const pending = await adapter.call(options.input, {
803
+ capabilities: [options.capability],
804
+ approval: options.approval,
805
+ })
806
+ if (!pending.ok && pending.error.code === "approval_required")
807
+ throw new Error("tool conformance: approval was not supplied")
808
+ checks.push("approval admission")
809
+ const dry = await adapter.call(options.input, {
810
+ capabilities: [options.capability],
811
+ approval: options.approval,
812
+ ...options.dryRun,
813
+ dryRun: true,
814
+ })
815
+ if (!dry.ok || !dry.dryRun || dry.evidence.every((item) => item.outcome !== "dry-run"))
816
+ throw new Error("tool conformance: dry-run failed")
817
+ checks.push("dry-run")
818
+ return Object.freeze({ adapter: adapter.name, checks: Object.freeze(checks) })
819
+ }
820
+
821
+ function isStandardSchema(value: unknown): value is StandardSchemaV1 {
822
+ if (value === null || typeof value !== "object") return false
823
+ const standard = (value as { readonly [key: string]: unknown })["~standard"]
824
+ return (
825
+ standard !== null &&
826
+ typeof standard === "object" &&
827
+ (standard as { readonly version?: unknown }).version === 1 &&
828
+ typeof (standard as { readonly validate?: unknown }).validate === "function"
829
+ )
830
+ }
831
+
832
+ function validateApprovalPolicy(policy: ToolApprovalPolicy): ToolApprovalPolicy {
833
+ if (policy.kind === "none" || policy.kind === "required")
834
+ return Object.freeze({ kind: policy.kind })
835
+ if (policy.kind === "threshold" && Number.isSafeInteger(policy.level) && policy.level > 0)
836
+ return Object.freeze({ kind: "threshold", level: policy.level })
837
+ throw new TypeError("tool contract: approval policy is invalid")
838
+ }
839
+
840
+ function validateIdempotencyPolicy<Input>(policy: ToolIdempotencyPolicy<Input>): void {
841
+ if (policy.scope !== "request" && policy.scope !== "durable")
842
+ throw new TypeError("tool contract: idempotency scope is invalid")
843
+ if (typeof policy.key !== "function")
844
+ throw new TypeError("tool contract: idempotency key must be a function")
845
+ }
846
+
847
+ function validateIdempotencyInput(input: {
848
+ readonly namespace: string
849
+ readonly key: string
850
+ }): void {
851
+ if (!validIdempotencyNamespace(input.namespace) || !validIdempotencyKey(input.key))
852
+ throw new TypeError("tool idempotency: namespace/key is invalid")
853
+ }
854
+
855
+ function reservationInput(input: {
856
+ readonly namespace: string
857
+ readonly key: string
858
+ readonly value: string
859
+ }): { readonly namespace: string; readonly key: string; readonly reservation: string } {
860
+ return { namespace: input.namespace, key: input.key, reservation: input.value }
861
+ }
862
+
863
+ function checkApproval(
864
+ policy: ToolApprovalPolicy,
865
+ approval: ToolApproval | undefined,
866
+ ): ToolError["code"] | undefined {
867
+ if (policy.kind === "none") return undefined
868
+ if (approval === undefined) return "approval_required"
869
+ if (!approval.granted) return "approval_denied"
870
+ if (policy.kind === "threshold" && (approval.level ?? 0) < policy.level) return "approval_denied"
871
+ return undefined
872
+ }
873
+
874
+ function hasCapability(values: readonly string[] | undefined, required: string): boolean {
875
+ if (!Array.isArray(values)) return false
876
+ for (const value of values)
877
+ if (typeof value !== "string" || !validCapabilityId(value)) return false
878
+ return values.includes(required)
879
+ }
880
+
881
+ function normalizeCost(value: EffectCost, label: string): Record<string, number> {
882
+ try {
883
+ const normalized = normalizeEffectMetadata({ cost: value }).cost
884
+ return { ...(normalized ?? {}) }
885
+ } catch (error) {
886
+ throw new TypeError(`${label}: ${error instanceof Error ? error.message : "invalid cost"}`)
887
+ }
888
+ }
889
+
890
+ function isSensitivity(value: string): value is ToolSensitivity {
891
+ return value === "public" || value === "internal" || value === "sensitive" || value === "secret"
892
+ }
893
+
894
+ function isAbortError(error: unknown): boolean {
895
+ return error instanceof DOMException && error.name === "AbortError"
896
+ }