@tangle-network/agent-interface 0.39.0 → 0.41.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.
@@ -30,33 +30,34 @@ const FieldBase = {
30
30
  /** Whether the answer must supply this field to be `accepted`. */
31
31
  required: z.boolean().optional(),
32
32
  };
33
- export const InteractionFieldSchema = z.discriminatedUnion("type", [
34
- z.object({
33
+ export const InteractionFieldSchema = z
34
+ .discriminatedUnion("type", [
35
+ z.strictObject({
35
36
  ...FieldBase,
36
37
  type: z.literal("text"),
37
38
  multiline: z.boolean().optional(),
38
39
  placeholder: z.string().optional(),
39
40
  default: z.string().optional(),
40
41
  }),
41
- z.object({
42
+ z.strictObject({
42
43
  ...FieldBase,
43
44
  type: z.literal("number"),
44
- min: z.number().optional(),
45
- max: z.number().optional(),
46
- default: z.number().optional(),
45
+ min: z.number().finite().optional(),
46
+ max: z.number().finite().optional(),
47
+ default: z.number().finite().optional(),
47
48
  }),
48
- z.object({
49
+ z.strictObject({
49
50
  ...FieldBase,
50
51
  type: z.literal("boolean"),
51
52
  default: z.boolean().optional(),
52
53
  }),
53
- z.object({
54
+ z.strictObject({
54
55
  ...FieldBase,
55
56
  type: z.literal("select"),
56
57
  options: z
57
- .array(z.object({
58
- value: z.string(),
59
- label: z.string(),
58
+ .array(z.strictObject({
59
+ value: z.string().min(1),
60
+ label: z.string().min(1),
60
61
  description: z.string().optional(),
61
62
  }))
62
63
  .min(1),
@@ -68,26 +69,179 @@ export const InteractionFieldSchema = z.discriminatedUnion("type", [
68
69
  * Write-ins must still be non-empty strings.
69
70
  */
70
71
  allowCustom: z.boolean().optional(),
71
- default: z.array(z.string()).optional(),
72
+ default: z.array(z.string().min(1)).optional(),
72
73
  }),
73
74
  /** Like `text` but the value is sensitive (token/key) and must be masked. */
74
- z.object({
75
+ z.strictObject({
75
76
  ...FieldBase,
76
77
  type: z.literal("secret"),
77
78
  placeholder: z.string().optional(),
78
79
  }),
79
- ]);
80
- export const InteractionAnswerSpecSchema = z.object({
80
+ ])
81
+ .superRefine((field, context) => {
82
+ if (field.type === "number") {
83
+ if (field.min !== undefined &&
84
+ field.max !== undefined &&
85
+ field.min > field.max) {
86
+ context.addIssue({
87
+ code: "custom",
88
+ path: ["max"],
89
+ message: "number field max must be greater than or equal to min",
90
+ });
91
+ }
92
+ if (field.default !== undefined &&
93
+ field.min !== undefined &&
94
+ field.default < field.min) {
95
+ context.addIssue({
96
+ code: "custom",
97
+ path: ["default"],
98
+ message: "number field default must be greater than or equal to min",
99
+ });
100
+ }
101
+ if (field.default !== undefined &&
102
+ field.max !== undefined &&
103
+ field.default > field.max) {
104
+ context.addIssue({
105
+ code: "custom",
106
+ path: ["default"],
107
+ message: "number field default must be less than or equal to max",
108
+ });
109
+ }
110
+ return;
111
+ }
112
+ if (field.type !== "select")
113
+ return;
114
+ const optionValues = field.options.map((option) => option.value);
115
+ if (new Set(optionValues).size !== optionValues.length) {
116
+ context.addIssue({
117
+ code: "custom",
118
+ path: ["options"],
119
+ message: "select option values must be unique",
120
+ });
121
+ }
122
+ if (field.default === undefined)
123
+ return;
124
+ if (new Set(field.default).size !== field.default.length) {
125
+ context.addIssue({
126
+ code: "custom",
127
+ path: ["default"],
128
+ message: "select default values must be unique",
129
+ });
130
+ }
131
+ if (!field.multi && field.default.length > 1) {
132
+ context.addIssue({
133
+ code: "custom",
134
+ path: ["default"],
135
+ message: "single-select default may contain at most one value",
136
+ });
137
+ }
138
+ if (field.required && field.default.length === 0) {
139
+ context.addIssue({
140
+ code: "custom",
141
+ path: ["default"],
142
+ message: "required select default must contain a value",
143
+ });
144
+ }
145
+ const allowed = new Set(optionValues);
146
+ for (const value of field.default) {
147
+ if (allowed.has(value))
148
+ continue;
149
+ if (field.allowCustom === true && value.trim().length > 0)
150
+ continue;
151
+ context.addIssue({
152
+ code: "custom",
153
+ path: ["default"],
154
+ message: `select default contains unknown option "${value}"`,
155
+ });
156
+ }
157
+ });
158
+ export const InteractionAnswerSpecSchema = z
159
+ .strictObject({
81
160
  fields: z.array(InteractionFieldSchema),
161
+ })
162
+ .superRefine((spec, context) => {
163
+ const names = spec.fields.map((field) => field.name);
164
+ if (new Set(names).size !== names.length) {
165
+ context.addIssue({
166
+ code: "custom",
167
+ path: ["fields"],
168
+ message: "interaction field names must be unique",
169
+ });
170
+ }
171
+ });
172
+ export const InteractionFieldTypeSchema = z.enum([
173
+ "text",
174
+ "number",
175
+ "boolean",
176
+ "select",
177
+ "secret",
178
+ ]);
179
+ /** Scope at which an accepted answer may be reused by an explicit policy. */
180
+ export const InteractionResponseScopeSchema = z.enum([
181
+ "interaction",
182
+ "session",
183
+ "persistent",
184
+ ]);
185
+ /** Negotiated interaction behavior. Absence means interactions are unsupported. */
186
+ export const InteractionCapabilitiesSchema = z
187
+ .strictObject({
188
+ kinds: z.array(z.string().min(1)).min(1),
189
+ answerFieldTypes: z.array(InteractionFieldTypeSchema).min(1),
190
+ responseScopes: z.array(InteractionResponseScopeSchema).min(1),
191
+ secretAnswers: z.boolean(),
192
+ concurrentRequests: z.boolean(),
193
+ replay: z.boolean(),
194
+ responseIdempotency: z.boolean(),
195
+ })
196
+ .superRefine((capabilities, context) => {
197
+ const advertisesSecret = capabilities.answerFieldTypes.includes("secret");
198
+ if (advertisesSecret !== capabilities.secretAnswers) {
199
+ context.addIssue({
200
+ code: "custom",
201
+ path: ["secretAnswers"],
202
+ message: "secretAnswers must agree with the secret answer field capability",
203
+ });
204
+ }
205
+ if (new Set(capabilities.kinds).size !== capabilities.kinds.length) {
206
+ context.addIssue({
207
+ code: "custom",
208
+ path: ["kinds"],
209
+ message: "interaction kinds must be unique",
210
+ });
211
+ }
212
+ if (new Set(capabilities.answerFieldTypes).size !==
213
+ capabilities.answerFieldTypes.length) {
214
+ context.addIssue({
215
+ code: "custom",
216
+ path: ["answerFieldTypes"],
217
+ message: "interaction answer field types must be unique",
218
+ });
219
+ }
220
+ if (new Set(capabilities.responseScopes).size !==
221
+ capabilities.responseScopes.length) {
222
+ context.addIssue({
223
+ code: "custom",
224
+ path: ["responseScopes"],
225
+ message: "interaction response scopes must be unique",
226
+ });
227
+ }
82
228
  });
83
229
  // =============================================================================
84
230
  // Subject — what the request is about (drives preview/permission UX).
85
231
  // =============================================================================
86
232
  export const InteractionSubjectSchema = z.discriminatedUnion("type", [
87
- z.object({ type: z.literal("tool"), toolName: z.string(), input: z.unknown().optional() }),
88
- z.object({ type: z.literal("command"), command: z.string() }),
89
- z.object({ type: z.literal("file"), path: z.string(), preview: z.string().optional() }),
90
- z.object({ type: z.literal("resource"), uri: z.string() }),
233
+ z.strictObject({
234
+ type: z.literal("tool"),
235
+ toolName: z.string(),
236
+ input: z.unknown().optional(),
237
+ }),
238
+ z.strictObject({ type: z.literal("command"), command: z.string() }),
239
+ z.strictObject({
240
+ type: z.literal("file"),
241
+ path: z.string(),
242
+ preview: z.string().optional(),
243
+ }),
244
+ z.strictObject({ type: z.literal("resource"), uri: z.string() }),
91
245
  ]);
92
246
  // =============================================================================
93
247
  // Outcome + resolution — the answer.
@@ -95,15 +249,27 @@ export const InteractionSubjectSchema = z.discriminatedUnion("type", [
95
249
  export const InteractionOutcomeSchema = z.enum(["accepted", "declined", "cancelled"]);
96
250
  /** Field values keyed by `InteractionField.name`. Validated against `answerSpec`. */
97
251
  export const InteractionDataSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.array(z.string())]));
98
- export const InteractionResolutionSchema = z.object({
99
- outcome: InteractionOutcomeSchema,
100
- /** Present (and validated) only when `outcome === "accepted"`. */
101
- data: InteractionDataSchema.optional(),
102
- });
252
+ export const InteractionResolutionSchema = z.discriminatedUnion("outcome", [
253
+ z.strictObject({
254
+ outcome: z.literal("accepted"),
255
+ data: InteractionDataSchema.optional(),
256
+ }),
257
+ z.strictObject({
258
+ outcome: z.literal("declined"),
259
+ /** Accepted for wire compatibility; ignored by response validation. */
260
+ data: InteractionDataSchema.optional(),
261
+ }),
262
+ z.strictObject({
263
+ outcome: z.literal("cancelled"),
264
+ /** Accepted for wire compatibility; ignored by response validation. */
265
+ data: InteractionDataSchema.optional(),
266
+ }),
267
+ ]);
103
268
  // =============================================================================
104
269
  // The request envelope.
105
270
  // =============================================================================
106
- export const InteractionRequestSchema = z.object({
271
+ export const InteractionRequestSchema = z
272
+ .strictObject({
107
273
  /** Correlation id; unique within a session. The response carries the same id. */
108
274
  id: z.string().min(1),
109
275
  /**
@@ -118,15 +284,131 @@ export const InteractionRequestSchema = z.object({
118
284
  body: z.string().optional(),
119
285
  subject: InteractionSubjectSchema.optional(),
120
286
  answerSpec: InteractionAnswerSpecSchema,
287
+ /** Omission is fail-closed and permits only this interaction. */
288
+ responseScopes: z.array(InteractionResponseScopeSchema).min(1).optional(),
121
289
  /** Resolution applied when unattended or timed out — explicit, not a bypass flag. */
122
290
  default: InteractionResolutionSchema.optional(),
123
291
  /** Wait this long for a human before applying `onTimeout`. */
124
292
  timeoutMs: z.number().int().positive().optional(),
125
293
  /** On timeout: apply `default`, `fail` the turn, or keep `wait`ing. Default `wait`. */
126
294
  onTimeout: z.enum(["default", "fail", "wait"]).optional(),
295
+ })
296
+ .superRefine((request, context) => {
297
+ if (request.responseScopes &&
298
+ new Set(request.responseScopes).size !== request.responseScopes.length) {
299
+ context.addIssue({
300
+ code: "custom",
301
+ path: ["responseScopes"],
302
+ message: "interaction response scopes must be unique",
303
+ });
304
+ }
305
+ if (request.onTimeout === "default" && request.default === undefined) {
306
+ context.addIssue({
307
+ code: "custom",
308
+ path: ["default"],
309
+ message: "onTimeout=default requires a default resolution",
310
+ });
311
+ }
312
+ if (request.default) {
313
+ for (const error of validateResolutionForRequest(request, request.default)) {
314
+ context.addIssue({ code: "custom", path: ["default"], message: error });
315
+ }
316
+ if (request.default.outcome === "accepted") {
317
+ const secretFields = new Set(request.answerSpec.fields
318
+ .filter((field) => field.type === "secret")
319
+ .map((field) => field.name));
320
+ for (const fieldName of Object.keys(request.default.data ?? {})) {
321
+ if (!secretFields.has(fieldName))
322
+ continue;
323
+ context.addIssue({
324
+ code: "custom",
325
+ path: ["default", "data", fieldName],
326
+ message: "secret answers cannot be embedded in interaction defaults",
327
+ });
328
+ }
329
+ }
330
+ }
127
331
  });
128
- export const InteractionResponseSchema = InteractionResolutionSchema.extend({
129
- id: z.string().min(1),
332
+ export const InteractionResponseSchema = z.discriminatedUnion("outcome", [
333
+ z.strictObject({
334
+ id: z.string().min(1),
335
+ outcome: z.literal("accepted"),
336
+ data: InteractionDataSchema.optional(),
337
+ }),
338
+ z.strictObject({
339
+ id: z.string().min(1),
340
+ outcome: z.literal("declined"),
341
+ /** Accepted for compatibility with the pre-discriminated wire shape. */
342
+ data: InteractionDataSchema.optional(),
343
+ }),
344
+ z.strictObject({
345
+ id: z.string().min(1),
346
+ outcome: z.literal("cancelled"),
347
+ /** Accepted for compatibility with the pre-discriminated wire shape. */
348
+ data: InteractionDataSchema.optional(),
349
+ }),
350
+ ]);
351
+ /** A request is unique only within this run and optional provider session. */
352
+ export const InteractionBindingSchema = z.strictObject({
353
+ runId: z.string().min(1),
354
+ environmentId: z.string().min(1),
355
+ sessionId: z.string().min(1).optional(),
356
+ interactionId: z.string().min(1),
357
+ });
358
+ /** Retryable command sent to an environment or retained session. */
359
+ export const InteractionResponseCommandSchema = z
360
+ .strictObject({
361
+ operationId: z.string().min(1),
362
+ binding: InteractionBindingSchema,
363
+ response: InteractionResponseSchema,
364
+ })
365
+ .superRefine((command, context) => {
366
+ if (command.binding.interactionId !== command.response.id) {
367
+ context.addIssue({
368
+ code: "custom",
369
+ path: ["response", "id"],
370
+ message: "response id must match the bound interaction id",
371
+ });
372
+ }
373
+ });
374
+ export const InteractionAcknowledgementStatusSchema = z.enum([
375
+ "accepted",
376
+ "already_resolved_same",
377
+ "already_resolved_different",
378
+ "expired",
379
+ "cancelled",
380
+ "unknown_interaction",
381
+ "unknown_run",
382
+ "binding_mismatch",
383
+ "invalid_response",
384
+ "transport_failure",
385
+ ]);
386
+ /** Durable result of one interaction response operation. */
387
+ export const InteractionAcknowledgementSchema = z
388
+ .strictObject({
389
+ operationId: z.string().min(1),
390
+ binding: InteractionBindingSchema,
391
+ status: InteractionAcknowledgementStatusSchema,
392
+ message: z.string().min(1).optional(),
393
+ retryable: z.boolean().optional(),
394
+ })
395
+ .superRefine((acknowledgement, context) => {
396
+ if (["invalid_response", "transport_failure"].includes(acknowledgement.status) &&
397
+ acknowledgement.message === undefined) {
398
+ context.addIssue({
399
+ code: "custom",
400
+ path: ["message"],
401
+ message: `${acknowledgement.status} must include a message`,
402
+ });
403
+ }
404
+ if (acknowledgement.status === "transport_failure" &&
405
+ acknowledgement.retryable === undefined) {
406
+ context.addIssue({
407
+ code: "custom",
408
+ path: ["retryable"],
409
+ message: "transport_failure must state whether retry is safe",
410
+ });
411
+ }
130
412
  });
131
413
  // =============================================================================
132
414
  // Well-known kinds + helpers.
@@ -150,20 +432,28 @@ export const PermissionGrantSchema = z.enum([
150
432
  "allow_always",
151
433
  "deny",
152
434
  ]);
153
- /** Build the answer spec for a `permission` interaction (graduated grant + feedback). */
435
+ /** Build a permission answer spec that cannot offer a broader reusable grant. */
154
436
  export function permissionAnswerSpec(opts) {
437
+ const scopes = new Set(opts?.responseScopes ?? ["interaction"]);
438
+ const options = [
439
+ ...(scopes.has("interaction")
440
+ ? [{ value: "allow_once", label: "Allow once" }]
441
+ : []),
442
+ ...(scopes.has("session")
443
+ ? [{ value: "allow_session", label: "Allow for this session" }]
444
+ : []),
445
+ ...(scopes.has("persistent")
446
+ ? [{ value: "allow_always", label: "Always allow" }]
447
+ : []),
448
+ { value: "deny", label: "Deny" },
449
+ ];
155
450
  const fields = [
156
451
  {
157
452
  type: "select",
158
453
  name: PERMISSION_GRANT_FIELD,
159
454
  label: "Decision",
160
455
  required: true,
161
- options: [
162
- { value: "allow_once", label: "Allow once" },
163
- { value: "allow_session", label: "Allow for this session" },
164
- { value: "allow_always", label: "Always allow" },
165
- { value: "deny", label: "Deny" },
166
- ],
456
+ options,
167
457
  },
168
458
  ];
169
459
  if (opts?.allowFeedback !== false) {
@@ -183,6 +473,12 @@ export function permissionAnswerSpec(opts) {
183
473
  export function validateInteractionAnswer(spec, data) {
184
474
  const errors = [];
185
475
  const d = data ?? {};
476
+ const knownFields = new Set(spec.fields.map((field) => field.name));
477
+ for (const fieldName of Object.keys(d)) {
478
+ if (!knownFields.has(fieldName)) {
479
+ errors.push(`unknown field "${fieldName}"`);
480
+ }
481
+ }
186
482
  for (const field of spec.fields) {
187
483
  const v = d[field.name];
188
484
  const present = v !== undefined && v !== null && !(typeof v === "string" && v === "");
@@ -198,8 +494,8 @@ export function validateInteractionAnswer(spec, data) {
198
494
  errors.push(`field "${field.name}" must be a string`);
199
495
  break;
200
496
  case "number":
201
- if (typeof v !== "number") {
202
- errors.push(`field "${field.name}" must be a number`);
497
+ if (typeof v !== "number" || !Number.isFinite(v)) {
498
+ errors.push(`field "${field.name}" must be a finite number`);
203
499
  }
204
500
  else {
205
501
  if (field.min !== undefined && v < field.min)
@@ -240,3 +536,48 @@ export function validateInteractionAnswer(spec, data) {
240
536
  }
241
537
  return errors.length === 0 ? { ok: true } : { ok: false, errors };
242
538
  }
539
+ /** Validate one response against the exact outstanding request. */
540
+ export function validateInteractionResponse(request, response) {
541
+ const parsed = InteractionResponseSchema.safeParse(response);
542
+ if (!parsed.success) {
543
+ return {
544
+ ok: false,
545
+ errors: parsed.error.issues.map((issue) => issue.message),
546
+ };
547
+ }
548
+ const errors = request.id === parsed.data.id
549
+ ? validateResolutionForRequest(request, parsed.data)
550
+ : ["response id does not match the outstanding interaction"];
551
+ return errors.length === 0 ? { ok: true } : { ok: false, errors };
552
+ }
553
+ function validateResolutionForRequest(request, resolution) {
554
+ if (resolution.outcome !== "accepted")
555
+ return [];
556
+ const validation = validateInteractionAnswer(request.answerSpec, resolution.data);
557
+ const errors = validation.ok ? [] : [...validation.errors];
558
+ if (request.kind !== InteractionKind.Permission)
559
+ return errors;
560
+ const grant = resolution.data?.[PERMISSION_GRANT_FIELD];
561
+ if (!Array.isArray(grant) || grant.length !== 1) {
562
+ errors.push('permission response must select exactly one "grant" value');
563
+ return errors;
564
+ }
565
+ const parsedGrant = PermissionGrantSchema.safeParse(grant[0]);
566
+ if (!parsedGrant.success) {
567
+ errors.push(`permission response has invalid grant "${String(grant[0])}"`);
568
+ return errors;
569
+ }
570
+ if (parsedGrant.data === "deny")
571
+ return errors;
572
+ const requiredScope = {
573
+ allow_once: "interaction",
574
+ allow_session: "session",
575
+ allow_always: "persistent",
576
+ deny: "interaction",
577
+ };
578
+ const permitted = new Set(request.responseScopes ?? ["interaction"]);
579
+ if (!permitted.has(requiredScope[parsedGrant.data])) {
580
+ errors.push(`permission grant "${parsedGrant.data}" exceeds the request's response scopes`);
581
+ }
582
+ return errors;
583
+ }