@nifrajs/webmcp 3.5.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/README.md +126 -0
- package/dist/index.d.ts +219 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +692 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
- package/src/index.ts +1031 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,1031 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nifra's typed Agent Surface contract and WebMCP adapter.
|
|
3
|
+
*
|
|
4
|
+
* WebMCP is the page-local transport: a compatible host discovers the standard
|
|
5
|
+
* `document.modelContext.registerTool` surface. Nifra adds typed core-tool
|
|
6
|
+
* execution, deterministic prediction/reconciliation, bounded receipts, and a
|
|
7
|
+
* host-independent conformance runner around that transport.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { type InferOutput, type StandardSchemaV1, validateStandard } from "@nifrajs/core/schema"
|
|
11
|
+
import { toCatalogEntry } from "@nifrajs/core/tool-catalog"
|
|
12
|
+
import {
|
|
13
|
+
defineTool,
|
|
14
|
+
executeTool,
|
|
15
|
+
type ToolApproval,
|
|
16
|
+
type ToolCallOptions,
|
|
17
|
+
type ToolCallResult,
|
|
18
|
+
type ToolContract,
|
|
19
|
+
type ToolContractOptions,
|
|
20
|
+
type ToolError,
|
|
21
|
+
type ToolEvidence,
|
|
22
|
+
} from "@nifrajs/core/tool-contract"
|
|
23
|
+
|
|
24
|
+
const MAX_ID_LENGTH = 128
|
|
25
|
+
const MAX_PATH_LENGTH = 256
|
|
26
|
+
const MAX_PATCH_OPERATIONS = 256
|
|
27
|
+
const MAX_ACTIVE_PREDICTIONS = 256
|
|
28
|
+
const MAX_PATCH_VALUE_BYTES = 64 * 1024
|
|
29
|
+
const DEFAULT_MAX_RECEIPT_BYTES = 16 * 1024
|
|
30
|
+
const MAX_RECEIPT_BYTES = 64 * 1024
|
|
31
|
+
const MAX_REGISTRATION_ERROR_LENGTH = 256
|
|
32
|
+
const VERSION_RE = /^[-A-Za-z0-9._:/]{1,128}$/
|
|
33
|
+
const STATE_PATH_RE = /^[a-z][A-Za-z0-9._:/-]{0,127}$/
|
|
34
|
+
const FORBIDDEN_PATH_SEGMENTS = new Set(["__proto__", "constructor", "prototype"])
|
|
35
|
+
|
|
36
|
+
export type AgentSurfaceScope = "page" | "session" | "server"
|
|
37
|
+
|
|
38
|
+
export type ReconciliationMode = "accept-server-state" | "reload" | "manual"
|
|
39
|
+
|
|
40
|
+
export interface AgentSurfaceDescriptor {
|
|
41
|
+
readonly scope: AgentSurfaceScope
|
|
42
|
+
readonly reads: readonly string[]
|
|
43
|
+
readonly writes: readonly string[]
|
|
44
|
+
readonly reconciliation: ReconciliationMode
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface AgentSurfaceSnapshot<State> {
|
|
48
|
+
readonly state: State
|
|
49
|
+
readonly version: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The safe RFC 6902 subset accepted for speculative UI updates. */
|
|
53
|
+
export type PredictionPatchOperation =
|
|
54
|
+
| { readonly op: "add" | "replace"; readonly path: string; readonly value: unknown }
|
|
55
|
+
| { readonly op: "remove"; readonly path: string }
|
|
56
|
+
|
|
57
|
+
export interface AgentSurfacePrediction {
|
|
58
|
+
readonly baseVersion: string
|
|
59
|
+
readonly patch: readonly PredictionPatchOperation[]
|
|
60
|
+
readonly expiresAt?: number
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface AgentSurfacePredictionContext<State, Input> {
|
|
64
|
+
readonly input: Input
|
|
65
|
+
readonly snapshot: State
|
|
66
|
+
readonly version: string
|
|
67
|
+
readonly now: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface AgentSurfaceAuthoritativeState<State> {
|
|
71
|
+
readonly state: State
|
|
72
|
+
readonly version: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface AgentSurfaceReconcileContext<Input, Output, State> {
|
|
76
|
+
readonly input: Input
|
|
77
|
+
readonly output: Output
|
|
78
|
+
readonly previous: AgentSurfaceSnapshot<State>
|
|
79
|
+
readonly prediction: AgentSurfacePrediction
|
|
80
|
+
readonly now: number
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type AgentSurfacePredictor<Input, State> = (
|
|
84
|
+
context: AgentSurfacePredictionContext<State, Input>,
|
|
85
|
+
) => AgentSurfacePrediction | PromiseLike<AgentSurfacePrediction>
|
|
86
|
+
|
|
87
|
+
export type AgentSurfaceReconciler<Input, Output, State> = (
|
|
88
|
+
context: AgentSurfaceReconcileContext<Input, Output, State>,
|
|
89
|
+
) => AgentSurfaceAuthoritativeState<State> | PromiseLike<AgentSurfaceAuthoritativeState<State>>
|
|
90
|
+
|
|
91
|
+
export interface AgentCapability<Input, Output, State = unknown> {
|
|
92
|
+
readonly tool: ToolContract<Input, Output>
|
|
93
|
+
readonly surface: AgentSurfaceDescriptor
|
|
94
|
+
readonly predict?: AgentSurfacePredictor<Input, State>
|
|
95
|
+
readonly reconcile?: AgentSurfaceReconciler<Input, Output, State>
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The erased, read-only handle accepted by capability registries.
|
|
100
|
+
*
|
|
101
|
+
* A real application commonly registers tools with unrelated input and output types. Registry
|
|
102
|
+
* boundaries therefore use this structural view instead of pretending the whole array has one
|
|
103
|
+
* homogeneous generic type. Individual calls remain validated and typed at their capability's
|
|
104
|
+
* `tool` boundary.
|
|
105
|
+
*/
|
|
106
|
+
export type AgentCapabilityReference = {
|
|
107
|
+
readonly tool: Omit<
|
|
108
|
+
ToolContract,
|
|
109
|
+
"input" | "output" | "execute" | "idempotency" | "cost" | "policy"
|
|
110
|
+
> & {
|
|
111
|
+
readonly input: StandardSchemaV1
|
|
112
|
+
readonly output: StandardSchemaV1
|
|
113
|
+
readonly execute: (...args: never[]) => unknown | PromiseLike<unknown>
|
|
114
|
+
readonly idempotency?: unknown
|
|
115
|
+
readonly cost?: unknown
|
|
116
|
+
readonly policy?: unknown
|
|
117
|
+
}
|
|
118
|
+
readonly surface: AgentSurfaceDescriptor
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export type DefineAgentCapabilityOptions<
|
|
122
|
+
InputSchema extends StandardSchemaV1,
|
|
123
|
+
OutputSchema extends StandardSchemaV1,
|
|
124
|
+
State,
|
|
125
|
+
> = ToolContractOptions<
|
|
126
|
+
InferOutput<InputSchema>,
|
|
127
|
+
InferOutput<OutputSchema>,
|
|
128
|
+
InputSchema,
|
|
129
|
+
OutputSchema
|
|
130
|
+
> & {
|
|
131
|
+
readonly scope?: AgentSurfaceScope
|
|
132
|
+
readonly reads?: readonly string[]
|
|
133
|
+
readonly writes?: readonly string[]
|
|
134
|
+
readonly reconciliation?: ReconciliationMode
|
|
135
|
+
readonly predict?: AgentSurfacePredictor<InferOutput<InputSchema>, State>
|
|
136
|
+
readonly reconcile?: AgentSurfaceReconciler<
|
|
137
|
+
InferOutput<InputSchema>,
|
|
138
|
+
InferOutput<OutputSchema>,
|
|
139
|
+
State
|
|
140
|
+
>
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Create a capability from the same core tool contract used by Nifra's other adapters. */
|
|
144
|
+
export function defineAgentCapability<
|
|
145
|
+
InputSchema extends StandardSchemaV1,
|
|
146
|
+
OutputSchema extends StandardSchemaV1,
|
|
147
|
+
State = unknown,
|
|
148
|
+
>(
|
|
149
|
+
options: DefineAgentCapabilityOptions<InputSchema, OutputSchema, State>,
|
|
150
|
+
): AgentCapability<InferOutput<InputSchema>, InferOutput<OutputSchema>, State> {
|
|
151
|
+
const tool = defineTool(options)
|
|
152
|
+
const scope = options.scope ?? "page"
|
|
153
|
+
if (scope !== "page" && scope !== "session" && scope !== "server")
|
|
154
|
+
throw new TypeError("agent surface: scope is invalid")
|
|
155
|
+
const reads = validateStatePaths(options.reads ?? [], "reads")
|
|
156
|
+
const writes = validateStatePaths(options.writes ?? [], "writes")
|
|
157
|
+
const reconciliation = options.reconciliation ?? "manual"
|
|
158
|
+
if (
|
|
159
|
+
reconciliation !== "accept-server-state" &&
|
|
160
|
+
reconciliation !== "reload" &&
|
|
161
|
+
reconciliation !== "manual"
|
|
162
|
+
)
|
|
163
|
+
throw new TypeError("agent surface: reconciliation is invalid")
|
|
164
|
+
if (options.predict !== undefined && options.reconcile === undefined)
|
|
165
|
+
throw new TypeError("agent surface: predict requires reconcile")
|
|
166
|
+
return Object.freeze({
|
|
167
|
+
tool,
|
|
168
|
+
surface: Object.freeze({
|
|
169
|
+
scope,
|
|
170
|
+
reads: Object.freeze(reads),
|
|
171
|
+
writes: Object.freeze(writes),
|
|
172
|
+
reconciliation,
|
|
173
|
+
}),
|
|
174
|
+
...(options.predict === undefined ? {} : { predict: options.predict }),
|
|
175
|
+
...(options.reconcile === undefined ? {} : { reconcile: options.reconcile }),
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function validateStatePaths(paths: readonly string[], label: string): readonly string[] {
|
|
180
|
+
if (!Array.isArray(paths)) throw new TypeError(`agent surface: ${label} must be an array`)
|
|
181
|
+
const unique = new Set<string>()
|
|
182
|
+
for (const path of paths) {
|
|
183
|
+
if (typeof path !== "string" || !STATE_PATH_RE.test(path))
|
|
184
|
+
throw new TypeError(`agent surface: ${label} contains an invalid path`)
|
|
185
|
+
unique.add(path)
|
|
186
|
+
}
|
|
187
|
+
return [...unique]
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface WebMcpToolDefinition {
|
|
191
|
+
readonly name: string
|
|
192
|
+
readonly description: string
|
|
193
|
+
readonly inputSchema: Record<string, unknown>
|
|
194
|
+
readonly annotations: AgentCapability<unknown, unknown>["tool"]["annotations"]
|
|
195
|
+
readonly execute: (input: unknown) => unknown | PromiseLike<unknown>
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface WebMcpExecutionOptions extends Omit<ToolCallOptions, "signal" | "capabilities"> {
|
|
199
|
+
/** Extra capability tokens may be supplied by an embedding host; the tool's own token is always added. */
|
|
200
|
+
readonly capabilities?: readonly string[]
|
|
201
|
+
/** A page-local authorization check. Server authorization remains mandatory for remote writes. */
|
|
202
|
+
readonly authorize?: (input: unknown) => boolean | PromiseLike<boolean>
|
|
203
|
+
/** Return the Nifra receipt envelope (default) or only the typed output on success. */
|
|
204
|
+
readonly resultMode?: "receipt" | "output"
|
|
205
|
+
/** Maximum JSON-encoded output bytes retained in the default receipt. */
|
|
206
|
+
readonly maxReceiptBytes?: number
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface WebMcpReceipt<Output = unknown> {
|
|
210
|
+
readonly ok: boolean
|
|
211
|
+
readonly outcome: "committed" | "dry-run" | "failed" | "denied"
|
|
212
|
+
readonly dryRun: boolean
|
|
213
|
+
readonly output?: Output
|
|
214
|
+
readonly outputTruncated?: true
|
|
215
|
+
readonly error?: { readonly code: ToolError["code"]; readonly stage: ToolError["stage"] }
|
|
216
|
+
readonly evidence: readonly ToolEvidence[]
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Adapt one canonical capability to the proposed WebMCP tool shape. */
|
|
220
|
+
export function toWebMcpTool<Input, Output, State>(
|
|
221
|
+
capability: AgentCapability<Input, Output, State>,
|
|
222
|
+
options: WebMcpExecutionOptions = {},
|
|
223
|
+
): WebMcpToolDefinition {
|
|
224
|
+
const maxReceiptBytes = validateReceiptLimit(options.maxReceiptBytes)
|
|
225
|
+
const entry = toCatalogEntry(capability.tool)
|
|
226
|
+
const fixedCapabilities = [capability.tool.capability, ...(options.capabilities ?? [])]
|
|
227
|
+
return {
|
|
228
|
+
name: entry.name,
|
|
229
|
+
description: entry.description,
|
|
230
|
+
inputSchema: entry.inputSchema,
|
|
231
|
+
annotations: entry.annotations,
|
|
232
|
+
execute: async (input) => {
|
|
233
|
+
if (options.authorize !== undefined) {
|
|
234
|
+
let authorized = false
|
|
235
|
+
try {
|
|
236
|
+
authorized = await options.authorize(input)
|
|
237
|
+
} catch {
|
|
238
|
+
// A page-local guard is untrusted adapter code. Treat a thrown guard as a denial rather
|
|
239
|
+
// than leaking its exception through the WebMCP host.
|
|
240
|
+
authorized = false
|
|
241
|
+
}
|
|
242
|
+
if (!authorized) return deniedReceipt(options.dryRun === true)
|
|
243
|
+
}
|
|
244
|
+
const result = await executeAgentCapability(capability, input, {
|
|
245
|
+
...options,
|
|
246
|
+
capabilities: fixedCapabilities,
|
|
247
|
+
})
|
|
248
|
+
const receipt = toReceipt(result, maxReceiptBytes)
|
|
249
|
+
// Raw output mode is explicitly opt-in and exists for hosts that already impose their own
|
|
250
|
+
// response limits. The default receipt path stays bounded.
|
|
251
|
+
if (options.resultMode === "output" && result.ok) return result.output
|
|
252
|
+
return receipt
|
|
253
|
+
},
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function deniedReceipt(dryRun: boolean): WebMcpReceipt {
|
|
258
|
+
return Object.freeze({
|
|
259
|
+
ok: false,
|
|
260
|
+
outcome: "denied",
|
|
261
|
+
dryRun,
|
|
262
|
+
error: { code: "capability_denied" as const, stage: "capability" as const },
|
|
263
|
+
evidence: Object.freeze([]),
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Execute through Nifra's existing validation, policy, approval, idempotency, and evidence path. */
|
|
268
|
+
export function executeAgentCapability<Input, Output, State>(
|
|
269
|
+
capability: AgentCapability<Input, Output, State>,
|
|
270
|
+
input: unknown,
|
|
271
|
+
options: ToolCallOptions = {},
|
|
272
|
+
): Promise<ToolCallResult<Output>> {
|
|
273
|
+
return executeTool(capability.tool, input, {
|
|
274
|
+
...options,
|
|
275
|
+
capabilities: [capability.tool.capability, ...(options.capabilities ?? [])],
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function toReceipt<Output>(
|
|
280
|
+
result: ToolCallResult<Output>,
|
|
281
|
+
maxReceiptBytes: number,
|
|
282
|
+
): WebMcpReceipt<Output> {
|
|
283
|
+
if (result.ok) {
|
|
284
|
+
const bounded = boundReceiptOutput(result.output, maxReceiptBytes)
|
|
285
|
+
return Object.freeze({
|
|
286
|
+
ok: true,
|
|
287
|
+
outcome: result.dryRun ? "dry-run" : "committed",
|
|
288
|
+
dryRun: result.dryRun,
|
|
289
|
+
...(bounded.output === undefined ? {} : { output: bounded.output }),
|
|
290
|
+
...(bounded.truncated ? { outputTruncated: true as const } : {}),
|
|
291
|
+
evidence: result.evidence,
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
return Object.freeze({
|
|
295
|
+
ok: false,
|
|
296
|
+
outcome: "failed",
|
|
297
|
+
dryRun: result.dryRun,
|
|
298
|
+
error: { code: result.error.code, stage: result.error.stage },
|
|
299
|
+
evidence: result.evidence,
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function validateReceiptLimit(value: number | undefined): number {
|
|
304
|
+
if (value === undefined) return DEFAULT_MAX_RECEIPT_BYTES
|
|
305
|
+
if (!Number.isSafeInteger(value) || value < 256 || value > MAX_RECEIPT_BYTES)
|
|
306
|
+
throw new RangeError(
|
|
307
|
+
`agent surface: maxReceiptBytes must be a safe integer between 256 and ${MAX_RECEIPT_BYTES}`,
|
|
308
|
+
)
|
|
309
|
+
return value
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function boundReceiptOutput<Output>(
|
|
313
|
+
output: Output | undefined,
|
|
314
|
+
maxBytes: number,
|
|
315
|
+
): { readonly output?: Output; readonly truncated?: true } {
|
|
316
|
+
if (output === undefined) return {}
|
|
317
|
+
try {
|
|
318
|
+
const encoded = new TextEncoder().encode(JSON.stringify(output))
|
|
319
|
+
return encoded.byteLength <= maxBytes ? { output } : { truncated: true }
|
|
320
|
+
} catch {
|
|
321
|
+
// A non-JSON output is not safe to put in a portable receipt. The typed output mode remains
|
|
322
|
+
// available to an embedding host that has its own serialization contract.
|
|
323
|
+
return { truncated: true }
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export interface WebMcpModelContext {
|
|
328
|
+
readonly registerTool: (tool: WebMcpToolDefinition) => void | PromiseLike<void>
|
|
329
|
+
readonly unregisterTool?: (name: string) => void | PromiseLike<void>
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export interface WebMcpTarget {
|
|
333
|
+
readonly modelContext?: WebMcpModelContext
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export interface WebMcpRegistrationFailure {
|
|
337
|
+
readonly name: string
|
|
338
|
+
readonly error: string
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export interface WebMcpRegistrationReport {
|
|
342
|
+
readonly supported: boolean
|
|
343
|
+
readonly registered: readonly string[]
|
|
344
|
+
readonly failed: readonly WebMcpRegistrationFailure[]
|
|
345
|
+
readonly cleanup: () => Promise<void>
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const targetRegistrations = new WeakMap<object, Set<string>>()
|
|
349
|
+
|
|
350
|
+
function defaultTarget(): WebMcpTarget | undefined {
|
|
351
|
+
const page = (globalThis as typeof globalThis & { readonly document?: WebMcpTarget }).document
|
|
352
|
+
return page
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Register an explicit allowlist of capabilities, safely degrading on ordinary browsers. */
|
|
356
|
+
export async function registerWebMcpTools(
|
|
357
|
+
capabilities: readonly AgentCapabilityReference[],
|
|
358
|
+
target: WebMcpTarget | undefined = defaultTarget(),
|
|
359
|
+
options: WebMcpExecutionOptions = {},
|
|
360
|
+
): Promise<WebMcpRegistrationReport> {
|
|
361
|
+
const context = target?.modelContext
|
|
362
|
+
if (context === undefined || typeof context.registerTool !== "function")
|
|
363
|
+
return Object.freeze({
|
|
364
|
+
supported: false,
|
|
365
|
+
registered: Object.freeze([]),
|
|
366
|
+
failed: Object.freeze([]),
|
|
367
|
+
cleanup: async () => {},
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
const key = context as object
|
|
371
|
+
const existing = targetRegistrations.get(key) ?? new Set<string>()
|
|
372
|
+
targetRegistrations.set(key, existing)
|
|
373
|
+
const registered: string[] = []
|
|
374
|
+
const failed: WebMcpRegistrationFailure[] = []
|
|
375
|
+
for (const capability of capabilities) {
|
|
376
|
+
const name = capability.tool.name
|
|
377
|
+
if (existing.has(name)) {
|
|
378
|
+
failed.push({ name, error: "duplicate tool name is already registered" })
|
|
379
|
+
continue
|
|
380
|
+
}
|
|
381
|
+
// Reserve before awaiting the host. `registerTool` may be asynchronous, and another caller can
|
|
382
|
+
// enter this function while it is pending. Keeping the reservation in the same set as committed
|
|
383
|
+
// names makes duplicate detection atomic from JavaScript's point of view; failures release only
|
|
384
|
+
// their own reservation so a later registration can retry safely.
|
|
385
|
+
existing.add(name)
|
|
386
|
+
try {
|
|
387
|
+
await context.registerTool(
|
|
388
|
+
toWebMcpTool(capability as unknown as AgentCapability<unknown, unknown, unknown>, options),
|
|
389
|
+
)
|
|
390
|
+
registered.push(name)
|
|
391
|
+
} catch (error) {
|
|
392
|
+
existing.delete(name)
|
|
393
|
+
if (existing.size === 0 && targetRegistrations.get(key) === existing)
|
|
394
|
+
targetRegistrations.delete(key)
|
|
395
|
+
failed.push({ name, error: describeError(error) })
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
let cleaned = false
|
|
399
|
+
const cleanup = async (): Promise<void> => {
|
|
400
|
+
if (cleaned) return
|
|
401
|
+
cleaned = true
|
|
402
|
+
if (context.unregisterTool === undefined) return
|
|
403
|
+
for (const name of [...registered].reverse()) {
|
|
404
|
+
try {
|
|
405
|
+
await context.unregisterTool(name)
|
|
406
|
+
existing.delete(name)
|
|
407
|
+
} catch {
|
|
408
|
+
// Cleanup is best effort; a host failure must not break the application.
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (existing.size === 0 && targetRegistrations.get(key) === existing)
|
|
412
|
+
targetRegistrations.delete(key)
|
|
413
|
+
}
|
|
414
|
+
return Object.freeze({
|
|
415
|
+
supported: true,
|
|
416
|
+
registered: Object.freeze([...registered]),
|
|
417
|
+
failed: Object.freeze([...failed]),
|
|
418
|
+
cleanup,
|
|
419
|
+
})
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function describeError(error: unknown): string {
|
|
423
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
424
|
+
return message.length <= MAX_REGISTRATION_ERROR_LENGTH
|
|
425
|
+
? message
|
|
426
|
+
: `${message.slice(0, MAX_REGISTRATION_ERROR_LENGTH - 3)}...`
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export interface PredictionStoreSnapshot<State> extends AgentSurfaceSnapshot<State> {
|
|
430
|
+
readonly authoritativeVersion: string
|
|
431
|
+
readonly activePredictionIds: readonly string[]
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export type PredictionStoreEvent<State> =
|
|
435
|
+
| {
|
|
436
|
+
readonly type: "predicted"
|
|
437
|
+
readonly predictionId: string
|
|
438
|
+
readonly snapshot: PredictionStoreSnapshot<State>
|
|
439
|
+
}
|
|
440
|
+
| {
|
|
441
|
+
readonly type: "committed"
|
|
442
|
+
readonly predictionId: string
|
|
443
|
+
readonly snapshot: PredictionStoreSnapshot<State>
|
|
444
|
+
}
|
|
445
|
+
| {
|
|
446
|
+
readonly type: "rolled-back"
|
|
447
|
+
readonly predictionId: string
|
|
448
|
+
readonly snapshot: PredictionStoreSnapshot<State>
|
|
449
|
+
}
|
|
450
|
+
| {
|
|
451
|
+
readonly type: "conflicted"
|
|
452
|
+
readonly predictionId: string
|
|
453
|
+
readonly snapshot: PredictionStoreSnapshot<State>
|
|
454
|
+
}
|
|
455
|
+
| {
|
|
456
|
+
readonly type: "expired"
|
|
457
|
+
readonly predictionId: string
|
|
458
|
+
readonly snapshot: PredictionStoreSnapshot<State>
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export type PredictionStoreResult<State> = {
|
|
462
|
+
readonly outcome:
|
|
463
|
+
| "predicted"
|
|
464
|
+
| "committed"
|
|
465
|
+
| "rolled-back"
|
|
466
|
+
| "conflicted"
|
|
467
|
+
| "expired"
|
|
468
|
+
| "stale"
|
|
469
|
+
| "invalid"
|
|
470
|
+
| "missing"
|
|
471
|
+
readonly predictionId: string
|
|
472
|
+
readonly snapshot: PredictionStoreSnapshot<State>
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export interface PredictionStore<State> {
|
|
476
|
+
readonly snapshot: () => PredictionStoreSnapshot<State>
|
|
477
|
+
readonly predict: (
|
|
478
|
+
predictionId: string,
|
|
479
|
+
prediction: AgentSurfacePrediction,
|
|
480
|
+
now?: number,
|
|
481
|
+
) => PredictionStoreResult<State>
|
|
482
|
+
readonly commit: (
|
|
483
|
+
predictionId: string,
|
|
484
|
+
authoritative: AgentSurfaceAuthoritativeState<State>,
|
|
485
|
+
now?: number,
|
|
486
|
+
) => PredictionStoreResult<State>
|
|
487
|
+
readonly rollback: (predictionId: string) => PredictionStoreResult<State>
|
|
488
|
+
readonly expire: (now?: number) => readonly string[]
|
|
489
|
+
readonly subscribe: (listener: (event: PredictionStoreEvent<State>) => void) => () => void
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
interface StoredPrediction {
|
|
493
|
+
readonly id: string
|
|
494
|
+
readonly prediction: AgentSurfacePrediction
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* An atomic, defensive prediction store. Predictions are overlays over authoritative state; a server
|
|
499
|
+
* commit replaces the base and remaining overlays are replayed or conflict-dropped.
|
|
500
|
+
*/
|
|
501
|
+
export function createPredictionStore<State>(
|
|
502
|
+
initial: AgentSurfaceAuthoritativeState<State>,
|
|
503
|
+
): PredictionStore<State> {
|
|
504
|
+
let authoritative = cloneValue(initial, "initial state")
|
|
505
|
+
validateVersion(initial.version)
|
|
506
|
+
const active = new Map<string, StoredPrediction>()
|
|
507
|
+
const conflicted = new Map<string, true>()
|
|
508
|
+
const order: string[] = []
|
|
509
|
+
const listeners = new Set<(event: PredictionStoreEvent<State>) => void>()
|
|
510
|
+
|
|
511
|
+
const removeActive = (id: string): void => {
|
|
512
|
+
active.delete(id)
|
|
513
|
+
const index = order.indexOf(id)
|
|
514
|
+
if (index !== -1) order.splice(index, 1)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const rememberConflict = (id: string): void => {
|
|
518
|
+
conflicted.delete(id)
|
|
519
|
+
if (conflicted.size >= MAX_ACTIVE_PREDICTIONS) {
|
|
520
|
+
const oldest = conflicted.keys().next().value
|
|
521
|
+
if (oldest !== undefined) conflicted.delete(oldest)
|
|
522
|
+
}
|
|
523
|
+
conflicted.set(id, true)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const snapshot = (): PredictionStoreSnapshot<State> => {
|
|
527
|
+
let state = cloneValue(authoritative.state, "authoritative state")
|
|
528
|
+
const invalid: string[] = []
|
|
529
|
+
for (const id of [...order]) {
|
|
530
|
+
const stored = active.get(id)
|
|
531
|
+
if (stored === undefined) continue
|
|
532
|
+
try {
|
|
533
|
+
state = applyPredictionPatch(state, stored.prediction.patch)
|
|
534
|
+
} catch {
|
|
535
|
+
invalid.push(id)
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
for (const id of invalid) {
|
|
539
|
+
removeActive(id)
|
|
540
|
+
rememberConflict(id)
|
|
541
|
+
}
|
|
542
|
+
return Object.freeze({
|
|
543
|
+
state,
|
|
544
|
+
version: authoritative.version,
|
|
545
|
+
authoritativeVersion: authoritative.version,
|
|
546
|
+
activePredictionIds: Object.freeze(order.filter((id) => active.has(id))),
|
|
547
|
+
})
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const emit = (event: PredictionStoreEvent<State>): void => {
|
|
551
|
+
for (const listener of listeners) {
|
|
552
|
+
try {
|
|
553
|
+
listener(event)
|
|
554
|
+
} catch {
|
|
555
|
+
// Observer failures cannot change the state machine.
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const predict = (
|
|
561
|
+
predictionId: string,
|
|
562
|
+
prediction: AgentSurfacePrediction,
|
|
563
|
+
now = Date.now(),
|
|
564
|
+
): PredictionStoreResult<State> => {
|
|
565
|
+
validateIdentifier(predictionId, "prediction id")
|
|
566
|
+
validatePrediction(prediction)
|
|
567
|
+
const current = snapshot()
|
|
568
|
+
if (active.has(predictionId) || conflicted.has(predictionId))
|
|
569
|
+
return { outcome: "invalid", predictionId, snapshot: current }
|
|
570
|
+
if (active.size >= MAX_ACTIVE_PREDICTIONS)
|
|
571
|
+
return { outcome: "invalid", predictionId, snapshot: current }
|
|
572
|
+
if (prediction.baseVersion !== authoritative.version)
|
|
573
|
+
return { outcome: "stale", predictionId, snapshot: current }
|
|
574
|
+
if (prediction.expiresAt !== undefined && prediction.expiresAt <= now)
|
|
575
|
+
return { outcome: "expired", predictionId, snapshot: current }
|
|
576
|
+
const candidate = applyPredictionPatch(current.state, prediction.patch)
|
|
577
|
+
// Clone before storing so caller mutation after predict cannot change the overlay.
|
|
578
|
+
const stored: StoredPrediction = {
|
|
579
|
+
id: predictionId,
|
|
580
|
+
prediction: Object.freeze({
|
|
581
|
+
baseVersion: prediction.baseVersion,
|
|
582
|
+
patch: Object.freeze(prediction.patch.map((op) => clonePatchOperation(op))),
|
|
583
|
+
...(prediction.expiresAt === undefined ? {} : { expiresAt: prediction.expiresAt }),
|
|
584
|
+
}),
|
|
585
|
+
}
|
|
586
|
+
active.set(predictionId, stored)
|
|
587
|
+
order.push(predictionId)
|
|
588
|
+
const next = snapshot()
|
|
589
|
+
// This should be impossible after candidate was applied; retain the guard as an atomicity check.
|
|
590
|
+
if (!deepEqual(candidate, next.state)) {
|
|
591
|
+
removeActive(predictionId)
|
|
592
|
+
return { outcome: "invalid", predictionId, snapshot: snapshot() }
|
|
593
|
+
}
|
|
594
|
+
const result = { outcome: "predicted" as const, predictionId, snapshot: next }
|
|
595
|
+
emit({ type: "predicted", predictionId, snapshot: next })
|
|
596
|
+
return result
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const discardUnreplayablePredictions = (): readonly string[] => {
|
|
600
|
+
const discarded: string[] = []
|
|
601
|
+
let projected = cloneValue(authoritative.state, "authoritative state")
|
|
602
|
+
for (const id of [...order]) {
|
|
603
|
+
const stored = active.get(id)
|
|
604
|
+
if (stored === undefined) continue
|
|
605
|
+
if (stored.prediction.baseVersion !== authoritative.version) {
|
|
606
|
+
removeActive(id)
|
|
607
|
+
rememberConflict(id)
|
|
608
|
+
discarded.push(id)
|
|
609
|
+
continue
|
|
610
|
+
}
|
|
611
|
+
try {
|
|
612
|
+
projected = applyPredictionPatch(projected, stored.prediction.patch)
|
|
613
|
+
} catch {
|
|
614
|
+
removeActive(id)
|
|
615
|
+
rememberConflict(id)
|
|
616
|
+
discarded.push(id)
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return Object.freeze(discarded)
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const commit = (
|
|
623
|
+
predictionId: string,
|
|
624
|
+
nextAuthoritative: AgentSurfaceAuthoritativeState<State>,
|
|
625
|
+
now = Date.now(),
|
|
626
|
+
): PredictionStoreResult<State> => {
|
|
627
|
+
validateIdentifier(predictionId, "prediction id")
|
|
628
|
+
const stored = active.get(predictionId)
|
|
629
|
+
const current = snapshot()
|
|
630
|
+
if (stored === undefined) {
|
|
631
|
+
if (conflicted.delete(predictionId))
|
|
632
|
+
return { outcome: "conflicted", predictionId, snapshot: current }
|
|
633
|
+
return { outcome: "missing", predictionId, snapshot: current }
|
|
634
|
+
}
|
|
635
|
+
if (stored.prediction.expiresAt !== undefined && stored.prediction.expiresAt <= now) {
|
|
636
|
+
removeActive(predictionId)
|
|
637
|
+
const expired = snapshot()
|
|
638
|
+
emit({ type: "expired", predictionId, snapshot: expired })
|
|
639
|
+
return { outcome: "expired", predictionId, snapshot: expired }
|
|
640
|
+
}
|
|
641
|
+
if (stored.prediction.baseVersion !== authoritative.version) {
|
|
642
|
+
removeActive(predictionId)
|
|
643
|
+
rememberConflict(predictionId)
|
|
644
|
+
const conflicted = snapshot()
|
|
645
|
+
emit({ type: "conflicted", predictionId, snapshot: conflicted })
|
|
646
|
+
return { outcome: "conflicted", predictionId, snapshot: conflicted }
|
|
647
|
+
}
|
|
648
|
+
validateVersion(nextAuthoritative.version)
|
|
649
|
+
const nextState = cloneValue(nextAuthoritative.state, "authoritative state")
|
|
650
|
+
removeActive(predictionId)
|
|
651
|
+
authoritative = { state: nextState, version: nextAuthoritative.version }
|
|
652
|
+
const discarded = discardUnreplayablePredictions()
|
|
653
|
+
const committed = snapshot()
|
|
654
|
+
emit({ type: "committed", predictionId, snapshot: committed })
|
|
655
|
+
for (const id of discarded) emit({ type: "conflicted", predictionId: id, snapshot: committed })
|
|
656
|
+
return { outcome: "committed", predictionId, snapshot: committed }
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const rollback = (predictionId: string): PredictionStoreResult<State> => {
|
|
660
|
+
validateIdentifier(predictionId, "prediction id")
|
|
661
|
+
if (!active.has(predictionId)) return { outcome: "missing", predictionId, snapshot: snapshot() }
|
|
662
|
+
removeActive(predictionId)
|
|
663
|
+
const rolledBack = snapshot()
|
|
664
|
+
emit({ type: "rolled-back", predictionId, snapshot: rolledBack })
|
|
665
|
+
return { outcome: "rolled-back", predictionId, snapshot: rolledBack }
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const expire = (now = Date.now()): readonly string[] => {
|
|
669
|
+
const expired: string[] = []
|
|
670
|
+
for (const id of [...order]) {
|
|
671
|
+
const stored = active.get(id)
|
|
672
|
+
if (stored?.prediction.expiresAt !== undefined && stored.prediction.expiresAt <= now) {
|
|
673
|
+
removeActive(id)
|
|
674
|
+
expired.push(id)
|
|
675
|
+
emit({ type: "expired", predictionId: id, snapshot: snapshot() })
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
return Object.freeze(expired)
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
return {
|
|
682
|
+
snapshot,
|
|
683
|
+
predict,
|
|
684
|
+
commit,
|
|
685
|
+
rollback,
|
|
686
|
+
expire,
|
|
687
|
+
subscribe(listener) {
|
|
688
|
+
listeners.add(listener)
|
|
689
|
+
return () => listeners.delete(listener)
|
|
690
|
+
},
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
export interface PredictedExecutionResult<Output, State> {
|
|
695
|
+
readonly outcome:
|
|
696
|
+
| "committed"
|
|
697
|
+
| "rolled-back"
|
|
698
|
+
| "conflicted"
|
|
699
|
+
| "expired"
|
|
700
|
+
| "stale"
|
|
701
|
+
| "invalid"
|
|
702
|
+
| "executed"
|
|
703
|
+
readonly predictionId?: string
|
|
704
|
+
readonly prediction?: AgentSurfacePrediction
|
|
705
|
+
readonly tool?: ToolCallResult<Output>
|
|
706
|
+
readonly snapshot: PredictionStoreSnapshot<State>
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
export interface ExecutePredictedOptions extends Omit<ToolCallOptions, "capabilities"> {
|
|
710
|
+
readonly capabilities?: readonly string[]
|
|
711
|
+
readonly predictionId?: string
|
|
712
|
+
readonly now?: () => number
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** Apply a pure prediction, execute the core tool, then commit or roll it back atomically. */
|
|
716
|
+
export async function executePredicted<Input, Output, State>(
|
|
717
|
+
capability: AgentCapability<Input, Output, State>,
|
|
718
|
+
input: unknown,
|
|
719
|
+
store: PredictionStore<State>,
|
|
720
|
+
options: ExecutePredictedOptions = {},
|
|
721
|
+
): Promise<PredictedExecutionResult<Output, State>> {
|
|
722
|
+
const now = options.now ?? Date.now
|
|
723
|
+
const validated = await validateStandard(capability.tool.input, input)
|
|
724
|
+
if (options.signal?.aborted) return { outcome: "rolled-back", snapshot: store.snapshot() }
|
|
725
|
+
if (!validated.ok) {
|
|
726
|
+
const tool = await executeAgentCapability(capability, input, options)
|
|
727
|
+
return {
|
|
728
|
+
outcome: "executed",
|
|
729
|
+
tool,
|
|
730
|
+
snapshot: store.snapshot(),
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
if (capability.predict === undefined) {
|
|
734
|
+
const tool = await executeAgentCapability(capability, input, options)
|
|
735
|
+
return {
|
|
736
|
+
outcome: tool.ok ? "executed" : "rolled-back",
|
|
737
|
+
tool,
|
|
738
|
+
snapshot: store.snapshot(),
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
if (capability.reconcile === undefined)
|
|
742
|
+
throw new TypeError("agent surface: prediction capability has no reconciler")
|
|
743
|
+
const before = store.snapshot()
|
|
744
|
+
const predictionInput = cloneValue(validated.value, "prediction input")
|
|
745
|
+
const prediction = await capability.predict({
|
|
746
|
+
input: predictionInput,
|
|
747
|
+
snapshot: before.state,
|
|
748
|
+
version: before.version,
|
|
749
|
+
now: now(),
|
|
750
|
+
})
|
|
751
|
+
if (options.signal?.aborted) return { outcome: "rolled-back", snapshot: store.snapshot() }
|
|
752
|
+
const predictionId = options.predictionId ?? crypto.randomUUID()
|
|
753
|
+
const proposed = store.predict(predictionId, prediction, now())
|
|
754
|
+
if (proposed.outcome !== "predicted")
|
|
755
|
+
return {
|
|
756
|
+
outcome: proposed.outcome === "missing" ? "invalid" : proposed.outcome,
|
|
757
|
+
predictionId,
|
|
758
|
+
prediction,
|
|
759
|
+
snapshot: proposed.snapshot,
|
|
760
|
+
}
|
|
761
|
+
const tool = await executeAgentCapability(capability, input, options)
|
|
762
|
+
if (!tool.ok || options.signal?.aborted) {
|
|
763
|
+
const rolledBack = store.rollback(predictionId)
|
|
764
|
+
return { outcome: "rolled-back", predictionId, prediction, tool, snapshot: rolledBack.snapshot }
|
|
765
|
+
}
|
|
766
|
+
let authoritative: AgentSurfaceAuthoritativeState<State>
|
|
767
|
+
try {
|
|
768
|
+
authoritative = await capability.reconcile({
|
|
769
|
+
input: cloneValue(predictionInput, "reconciliation input"),
|
|
770
|
+
output: cloneValue(tool.output as Output, "reconciliation output"),
|
|
771
|
+
previous: before,
|
|
772
|
+
prediction,
|
|
773
|
+
now: now(),
|
|
774
|
+
})
|
|
775
|
+
} catch {
|
|
776
|
+
const rolledBack = store.rollback(predictionId)
|
|
777
|
+
return { outcome: "rolled-back", predictionId, prediction, tool, snapshot: rolledBack.snapshot }
|
|
778
|
+
}
|
|
779
|
+
if (options.signal?.aborted) {
|
|
780
|
+
const rolledBack = store.rollback(predictionId)
|
|
781
|
+
return { outcome: "rolled-back", predictionId, prediction, tool, snapshot: rolledBack.snapshot }
|
|
782
|
+
}
|
|
783
|
+
const committed = store.commit(predictionId, authoritative, now())
|
|
784
|
+
return {
|
|
785
|
+
outcome:
|
|
786
|
+
committed.outcome === "committed"
|
|
787
|
+
? "committed"
|
|
788
|
+
: committed.outcome === "expired"
|
|
789
|
+
? "expired"
|
|
790
|
+
: "conflicted",
|
|
791
|
+
predictionId,
|
|
792
|
+
prediction,
|
|
793
|
+
tool,
|
|
794
|
+
snapshot: committed.snapshot,
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
export interface AgentSurfaceConformanceCase {
|
|
799
|
+
readonly capability: AgentCapabilityReference
|
|
800
|
+
readonly input: unknown
|
|
801
|
+
readonly assert?: (result: unknown) => void | PromiseLike<void>
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
export interface AgentSurfaceConformanceOptions {
|
|
805
|
+
readonly target?: WebMcpTarget
|
|
806
|
+
readonly registration?: WebMcpExecutionOptions
|
|
807
|
+
readonly cases?: readonly AgentSurfaceConformanceCase[]
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
export interface AgentSurfaceConformanceResult {
|
|
811
|
+
readonly supported: boolean
|
|
812
|
+
readonly registered: readonly string[]
|
|
813
|
+
readonly failed: readonly WebMcpRegistrationFailure[]
|
|
814
|
+
readonly checks: readonly string[]
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** Run deterministic host-independent checks over the WebMCP projection and optional calls. */
|
|
818
|
+
export async function runAgentSurfaceConformance(
|
|
819
|
+
capabilities: readonly AgentCapabilityReference[],
|
|
820
|
+
options: AgentSurfaceConformanceOptions = {},
|
|
821
|
+
): Promise<AgentSurfaceConformanceResult> {
|
|
822
|
+
const checks: string[] = []
|
|
823
|
+
const names = new Set<string>()
|
|
824
|
+
for (const capability of capabilities) {
|
|
825
|
+
if (names.has(capability.tool.name))
|
|
826
|
+
throw new Error(`agent surface conformance: duplicate ${capability.tool.name}`)
|
|
827
|
+
names.add(capability.tool.name)
|
|
828
|
+
const tool = toWebMcpTool(capability as unknown as AgentCapability<unknown, unknown, unknown>)
|
|
829
|
+
if (tool.name !== capability.tool.name || typeof tool.execute !== "function")
|
|
830
|
+
throw new Error(`agent surface conformance: invalid descriptor ${capability.tool.name}`)
|
|
831
|
+
}
|
|
832
|
+
checks.push("unique standard descriptors")
|
|
833
|
+
const report = await registerWebMcpTools(capabilities, options.target, options.registration)
|
|
834
|
+
if (!report.supported) {
|
|
835
|
+
checks.push("unsupported host degrades safely")
|
|
836
|
+
return Object.freeze({
|
|
837
|
+
supported: false,
|
|
838
|
+
registered: report.registered,
|
|
839
|
+
failed: report.failed,
|
|
840
|
+
checks: Object.freeze(checks),
|
|
841
|
+
})
|
|
842
|
+
}
|
|
843
|
+
try {
|
|
844
|
+
checks.push("host registration")
|
|
845
|
+
const byName = new Map(capabilities.map((capability) => [capability.tool.name, capability]))
|
|
846
|
+
for (const testCase of options.cases ?? []) {
|
|
847
|
+
const capability = byName.get(testCase.capability.tool.name)
|
|
848
|
+
if (capability === undefined)
|
|
849
|
+
throw new Error(`agent surface conformance: unknown case ${testCase.capability.tool.name}`)
|
|
850
|
+
const result = await toWebMcpTool(
|
|
851
|
+
capability as unknown as AgentCapability<unknown, unknown, unknown>,
|
|
852
|
+
options.registration,
|
|
853
|
+
).execute(testCase.input)
|
|
854
|
+
await testCase.assert?.(result)
|
|
855
|
+
checks.push(`call ${capability.tool.name}`)
|
|
856
|
+
}
|
|
857
|
+
} finally {
|
|
858
|
+
await report.cleanup()
|
|
859
|
+
}
|
|
860
|
+
return Object.freeze({
|
|
861
|
+
supported: true,
|
|
862
|
+
registered: report.registered,
|
|
863
|
+
failed: report.failed,
|
|
864
|
+
checks: Object.freeze(checks),
|
|
865
|
+
})
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function validateIdentifier(value: string, label: string): void {
|
|
869
|
+
if (typeof value !== "string" || value.length === 0 || value.length > MAX_ID_LENGTH)
|
|
870
|
+
throw new TypeError(`agent surface: ${label} is invalid`)
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function validateVersion(version: string): void {
|
|
874
|
+
if (typeof version !== "string" || !VERSION_RE.test(version))
|
|
875
|
+
throw new TypeError("agent surface: version is invalid")
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function validatePrediction(prediction: AgentSurfacePrediction): void {
|
|
879
|
+
validateVersion(prediction.baseVersion)
|
|
880
|
+
if (!Array.isArray(prediction.patch) || prediction.patch.length > MAX_PATCH_OPERATIONS)
|
|
881
|
+
throw new TypeError("agent surface: prediction patch is invalid")
|
|
882
|
+
if (prediction.expiresAt !== undefined && !Number.isSafeInteger(prediction.expiresAt))
|
|
883
|
+
throw new TypeError("agent surface: prediction expiry is invalid")
|
|
884
|
+
for (const operation of prediction.patch) {
|
|
885
|
+
if (operation === null || typeof operation !== "object")
|
|
886
|
+
throw new TypeError("agent surface: prediction operation is invalid")
|
|
887
|
+
if (operation.op !== "add" && operation.op !== "replace" && operation.op !== "remove")
|
|
888
|
+
throw new TypeError("agent surface: prediction operation is unsupported")
|
|
889
|
+
if (typeof operation.path !== "string" || operation.path.length > MAX_PATH_LENGTH)
|
|
890
|
+
throw new TypeError("agent surface: prediction path is invalid")
|
|
891
|
+
decodePointer(operation.path)
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function cloneValue<T>(value: T, label: string): T {
|
|
896
|
+
try {
|
|
897
|
+
return structuredClone(value)
|
|
898
|
+
} catch {
|
|
899
|
+
throw new TypeError(`agent surface: ${label} must be structured-cloneable`)
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function clonePatchOperation(operation: PredictionPatchOperation): PredictionPatchOperation {
|
|
904
|
+
if (operation.op === "remove") return { op: "remove", path: operation.path }
|
|
905
|
+
return {
|
|
906
|
+
op: operation.op,
|
|
907
|
+
path: operation.path,
|
|
908
|
+
value: clonePatchValue(operation.value),
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function clonePatchValue<T>(value: T): T {
|
|
913
|
+
try {
|
|
914
|
+
const cloned = structuredClone(value)
|
|
915
|
+
const encoded = JSON.stringify(cloned)
|
|
916
|
+
if (encoded === undefined)
|
|
917
|
+
throw new TypeError("agent surface: patch value must be JSON-serializable")
|
|
918
|
+
if (new TextEncoder().encode(encoded).byteLength > MAX_PATCH_VALUE_BYTES)
|
|
919
|
+
throw new RangeError("agent surface: patch value exceeds its size limit")
|
|
920
|
+
return cloned
|
|
921
|
+
} catch (error) {
|
|
922
|
+
if (error instanceof RangeError || error instanceof TypeError) throw error
|
|
923
|
+
throw new TypeError("agent surface: patch value must be structured-cloneable JSON")
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function deepEqual(a: unknown, b: unknown): boolean {
|
|
928
|
+
try {
|
|
929
|
+
return JSON.stringify(a) === JSON.stringify(b)
|
|
930
|
+
} catch {
|
|
931
|
+
return false
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function decodePointer(path: string): readonly string[] {
|
|
936
|
+
if (path === "") return Object.freeze([])
|
|
937
|
+
if (!path.startsWith("/")) throw new TypeError("agent surface: patch path must be a JSON Pointer")
|
|
938
|
+
const segments = path
|
|
939
|
+
.slice(1)
|
|
940
|
+
.split("/")
|
|
941
|
+
.map((segment) => {
|
|
942
|
+
for (let index = 0; index < segment.length; index += 1) {
|
|
943
|
+
if (segment[index] === "~" && segment[index + 1] !== "0" && segment[index + 1] !== "1")
|
|
944
|
+
throw new TypeError("agent surface: patch pointer escape is invalid")
|
|
945
|
+
}
|
|
946
|
+
return segment.replace(/~1/g, "/").replace(/~0/g, "~")
|
|
947
|
+
})
|
|
948
|
+
if (segments.some((segment) => FORBIDDEN_PATH_SEGMENTS.has(segment)))
|
|
949
|
+
throw new TypeError("agent surface: prototype path is forbidden")
|
|
950
|
+
return Object.freeze(segments)
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
954
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function applyPredictionPatch<State>(
|
|
958
|
+
state: State,
|
|
959
|
+
operations: readonly PredictionPatchOperation[],
|
|
960
|
+
): State {
|
|
961
|
+
let current: unknown = cloneValue(state, "prediction state")
|
|
962
|
+
for (const operation of operations) current = applyPredictionOperation(current, operation)
|
|
963
|
+
return current as State
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function applyPredictionOperation(document: unknown, operation: PredictionPatchOperation): unknown {
|
|
967
|
+
const segments = decodePointer(operation.path)
|
|
968
|
+
if (segments.length === 0) {
|
|
969
|
+
if (operation.op === "remove") throw new TypeError("agent surface: root removal is forbidden")
|
|
970
|
+
return clonePatchValue(operation.value)
|
|
971
|
+
}
|
|
972
|
+
const parentSegments = segments.slice(0, -1)
|
|
973
|
+
const leaf = segments[segments.length - 1]!
|
|
974
|
+
const parent = readPointer(document, parentSegments)
|
|
975
|
+
if (Array.isArray(parent)) {
|
|
976
|
+
if (operation.op === "add") {
|
|
977
|
+
if (leaf === "-") parent.push(clonePatchValue(operation.value))
|
|
978
|
+
else {
|
|
979
|
+
const index = arrayIndex(leaf, parent.length, true)
|
|
980
|
+
parent.splice(index, 0, clonePatchValue(operation.value))
|
|
981
|
+
}
|
|
982
|
+
return document
|
|
983
|
+
}
|
|
984
|
+
const index = arrayIndex(leaf, parent.length, false)
|
|
985
|
+
if (operation.op === "replace") parent[index] = clonePatchValue(operation.value)
|
|
986
|
+
else parent.splice(index, 1)
|
|
987
|
+
return document
|
|
988
|
+
}
|
|
989
|
+
if (!isRecord(parent)) throw new TypeError("agent surface: patch parent is not an object")
|
|
990
|
+
if (operation.op === "replace" && !Object.hasOwn(parent, leaf))
|
|
991
|
+
throw new TypeError("agent surface: replace target is missing")
|
|
992
|
+
if (operation.op === "remove" && !Object.hasOwn(parent, leaf))
|
|
993
|
+
throw new TypeError("agent surface: remove target is missing")
|
|
994
|
+
if (operation.op === "remove") delete parent[leaf]
|
|
995
|
+
else parent[leaf] = clonePatchValue(operation.value)
|
|
996
|
+
return document
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function readPointer(document: unknown, segments: readonly string[]): unknown {
|
|
1000
|
+
let current = document
|
|
1001
|
+
for (const segment of segments) {
|
|
1002
|
+
if (Array.isArray(current)) current = current[arrayIndex(segment, current.length, false)]
|
|
1003
|
+
else if (isRecord(current) && Object.hasOwn(current, segment)) current = current[segment]
|
|
1004
|
+
else throw new TypeError("agent surface: patch parent is missing")
|
|
1005
|
+
}
|
|
1006
|
+
return current
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function arrayIndex(segment: string, length: number, allowEnd: boolean): number {
|
|
1010
|
+
if (!/^(?:0|[1-9]\d*)$/.test(segment))
|
|
1011
|
+
throw new TypeError("agent surface: array index is invalid")
|
|
1012
|
+
const index = Number(segment)
|
|
1013
|
+
if (
|
|
1014
|
+
!Number.isSafeInteger(index) ||
|
|
1015
|
+
index < 0 ||
|
|
1016
|
+
index > length ||
|
|
1017
|
+
(!allowEnd && index === length)
|
|
1018
|
+
)
|
|
1019
|
+
throw new TypeError("agent surface: array index is out of bounds")
|
|
1020
|
+
return index
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
export type {
|
|
1024
|
+
ToolApproval,
|
|
1025
|
+
ToolCallOptions,
|
|
1026
|
+
ToolCallResult,
|
|
1027
|
+
ToolContract,
|
|
1028
|
+
ToolContractOptions,
|
|
1029
|
+
ToolError,
|
|
1030
|
+
ToolEvidence,
|
|
1031
|
+
}
|