@nifrajs/core 2.11.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.
- package/dist/assurance.d.ts +61 -3
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +152 -5
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +9 -1
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +5 -1
- package/dist/capabilities.js.map +1 -1
- package/dist/diff.d.ts +2 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +3 -1
- package/dist/diff.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/evidence.d.ts +89 -0
- package/dist/evidence.d.ts.map +1 -0
- package/dist/evidence.js +170 -0
- package/dist/evidence.js.map +1 -0
- package/dist/execution-policy.d.ts +23 -0
- package/dist/execution-policy.d.ts.map +1 -0
- package/dist/execution-policy.js +28 -0
- package/dist/execution-policy.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/route-assurance.d.ts +19 -0
- package/dist/internal/route-assurance.d.ts.map +1 -1
- package/dist/internal/route-assurance.js +38 -5
- package/dist/internal/route-assurance.js.map +1 -1
- package/dist/internal/route-execution.d.ts +18 -0
- package/dist/internal/route-execution.d.ts.map +1 -1
- package/dist/internal/route-execution.js +67 -1
- package/dist/internal/route-execution.js.map +1 -1
- package/dist/manifest.d.ts +5 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +22 -4
- package/dist/manifest.js.map +1 -1
- package/dist/reflection.d.ts +5 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +13 -4
- package/dist/reflection.js.map +1 -1
- package/dist/replay.d.ts +26 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +63 -0
- package/dist/replay.js.map +1 -0
- package/dist/schema/standard.d.ts +2 -0
- package/dist/schema/standard.d.ts.map +1 -1
- package/dist/schema/standard.js +13 -0
- package/dist/schema/standard.js.map +1 -1
- package/dist/server/body.d.ts +49 -6
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +226 -8
- package/dist/server/body.js.map +1 -1
- package/dist/server/context.d.ts +20 -3
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/contract.d.ts +15 -3
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +22 -0
- package/dist/server/contract.js.map +1 -1
- package/dist/server/cookies.d.ts +26 -5
- package/dist/server/cookies.d.ts.map +1 -1
- package/dist/server/cookies.js +76 -9
- package/dist/server/cookies.js.map +1 -1
- package/dist/server/edge.d.ts.map +1 -1
- package/dist/server/edge.js +11 -0
- package/dist/server/edge.js.map +1 -1
- package/dist/server/header-case.d.ts +9 -0
- package/dist/server/header-case.d.ts.map +1 -0
- package/dist/server/header-case.js +54 -0
- package/dist/server/header-case.js.map +1 -0
- package/dist/server/headers.d.ts +8 -0
- package/dist/server/headers.d.ts.map +1 -0
- package/dist/server/headers.js +13 -0
- package/dist/server/headers.js.map +1 -0
- package/dist/server/node-outcome-hook.d.ts.map +1 -1
- package/dist/server/node-outcome-hook.js +7 -11
- package/dist/server/node-outcome-hook.js.map +1 -1
- package/dist/server/node-outcome.d.ts +8 -1
- package/dist/server/node-outcome.d.ts.map +1 -1
- package/dist/server/node-outcome.js +10 -3
- package/dist/server/node-outcome.js.map +1 -1
- package/dist/server/plugin.d.ts +66 -7
- package/dist/server/plugin.d.ts.map +1 -1
- package/dist/server/plugin.js +14 -10
- package/dist/server/plugin.js.map +1 -1
- package/dist/server/proto-guard.d.ts +30 -0
- package/dist/server/proto-guard.d.ts.map +1 -0
- package/dist/server/proto-guard.js +116 -0
- package/dist/server/proto-guard.js.map +1 -0
- package/dist/server/registry.d.ts +6 -2
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +24 -6
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +157 -30
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts +2 -2
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +34 -8
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +1 -1
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +45 -13
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/response-hooks.d.ts +10 -0
- package/dist/server/response-hooks.d.ts.map +1 -0
- package/dist/server/response-hooks.js +52 -0
- package/dist/server/response-hooks.js.map +1 -0
- package/dist/server/runtime-core.d.ts +12 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js +8 -0
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +60 -6
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +80 -23
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +424 -213
- package/dist/server/server.js.map +1 -1
- package/dist/server/static-headers.d.ts +14 -3
- package/dist/server/static-headers.d.ts.map +1 -1
- package/dist/server/static-headers.js +28 -6
- package/dist/server/static-headers.js.map +1 -1
- package/dist/server/transport-lane.d.ts +8 -0
- package/dist/server/transport-lane.d.ts.map +1 -1
- package/dist/server/transport-lane.js +12 -8
- package/dist/server/transport-lane.js.map +1 -1
- package/dist/server/websocket.d.ts +3 -0
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +14 -0
- package/dist/server/websocket.js.map +1 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +2 -2
- package/dist/server.js.map +1 -1
- package/dist/tool-catalog.d.ts +21 -0
- package/dist/tool-catalog.d.ts.map +1 -0
- package/dist/tool-catalog.js +16 -0
- package/dist/tool-catalog.js.map +1 -0
- package/dist/tool-contract.d.ts +238 -0
- package/dist/tool-contract.d.ts.map +1 -0
- package/dist/tool-contract.js +569 -0
- package/dist/tool-contract.js.map +1 -0
- package/package.json +26 -1
- package/src/assurance.ts +236 -6
- package/src/capabilities.ts +14 -1
- package/src/diff.ts +5 -2
- package/src/errors.ts +1 -0
- package/src/evidence.ts +319 -0
- package/src/execution-policy.ts +57 -0
- package/src/index.ts +1 -1
- package/src/internal/route-assurance.ts +67 -4
- package/src/internal/route-execution.ts +276 -1
- package/src/manifest.ts +27 -5
- package/src/reflection.ts +24 -4
- package/src/replay.ts +96 -0
- package/src/schema/standard.ts +16 -0
- package/src/server/body.ts +275 -7
- package/src/server/context.ts +23 -3
- package/src/server/contract.ts +43 -3
- package/src/server/cookies.ts +80 -10
- package/src/server/edge.ts +10 -0
- package/src/server/header-case.ts +54 -0
- package/src/server/headers.ts +11 -0
- package/src/server/node-outcome-hook.ts +6 -11
- package/src/server/node-outcome.ts +10 -2
- package/src/server/plugin.ts +87 -8
- package/src/server/proto-guard.ts +118 -0
- package/src/server/registry.ts +6 -2
- package/src/server/request-context.ts +242 -26
- package/src/server/respond.ts +35 -4
- package/src/server/response-contract-lane.ts +6 -17
- package/src/server/response-hooks.ts +51 -0
- package/src/server/runtime-core.ts +14 -0
- package/src/server/server-types.ts +66 -6
- package/src/server/server.ts +667 -287
- package/src/server/static-headers.ts +27 -3
- package/src/server/transport-lane.ts +19 -8
- package/src/server/websocket.ts +18 -2
- package/src/server.ts +7 -0
- package/src/tool-catalog.ts +40 -0
- 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
|
+
}
|