Skip to content

Commit c63e325

Browse files
authored
feat(agent-interface): generalized interaction contract (#1)
1 parent 97a8f9c commit c63e325

3 files changed

Lines changed: 334 additions & 1 deletion

File tree

.changeset/interaction-contract.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@tangle-network/agent-interface": minor
3+
---
4+
5+
Add the generalized interaction contract for human-in-the-loop.
6+
7+
`InteractionRequest`/`InteractionResponse` envelope with a self-describing `answerSpec` (text/number/boolean/select/secret fields), an open `kind` label (well-known: `question`, `permission`, `plan`), graduated `PermissionGrant` values, generic `validateInteractionAnswer`, the `respondToInteraction` adapter method, and a `BackendCapabilities.interactions` declaration. New ask types need no contract change; the shape mirrors MCP elicitation. The legacy `question` stream event and `submitQuestionAnswer` adapter method remain (deprecated) for back-compat.

packages/agent-interface/src/index.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,21 @@
55
* This package defines the contract between the sidecar and provider implementations.
66
*/
77

8+
import type { InteractionRequest, InteractionResponse } from "./interaction.js";
9+
810
// Capabilities describe what a provider supports
911
export type BackendCapabilities = {
1012
streaming: boolean;
1113
toolUse: boolean;
1214
reasoning: boolean;
1315
multimodal: boolean;
1416
contextWindow: number;
17+
/**
18+
* Interaction kinds this provider can originate (e.g. `["question",
19+
* "permission"]`). Empty/undefined means the provider never asks the user.
20+
* Consumers use this to decide what human-in-the-loop UI to offer.
21+
*/
22+
interactions?: string[];
1523
};
1624

1725
/**
@@ -300,6 +308,20 @@ export type StreamEvent =
300308
time?: { created?: number; updated?: number };
301309
}
302310
// === INTERACTIVE EVENTS ===
311+
/** Agent asks the user; answered via `respondToInteraction`. The generalized
312+
* human-in-the-loop event (question, permission, plan, …). */
313+
| {
314+
type: "interaction";
315+
request: InteractionRequest;
316+
}
317+
/** Agent withdraws an outstanding interaction (no longer needs the answer). */
318+
| {
319+
type: "interaction.cancel";
320+
id: string;
321+
reason?: string;
322+
}
323+
/** @deprecated Use the `interaction` event with `kind: "question"`. Retained
324+
* so existing emitters/consumers keep working during migration. */
303325
| {
304326
type: "question";
305327
questionId: string;
@@ -785,9 +807,19 @@ export interface SdkProviderAdapter {
785807
): Promise<unknown>;
786808
listArtifacts?(sessionId: string): Promise<BackendArtifact[]>;
787809
downloadArtifact?(sessionId: string, path: string): Promise<Uint8Array>;
788-
// Optional question/answer for interactive prompts
810+
/**
811+
* Respond to an outstanding interaction (question, permission, …). The
812+
* generalized inbound channel; the adapter translates the response into the
813+
* provider's native control call to unblock the agent.
814+
*/
815+
respondToInteraction?(response: InteractionResponse): Promise<void>;
816+
/**
817+
* @deprecated Use `respondToInteraction`. Retained for back-compat; an
818+
* adapter implementing only this still answers `kind: "question"` asks.
819+
*/
789820
submitQuestionAnswer?(answers: Record<string, string[]>): Promise<void>;
790821
}
822+
export * from "./interaction.js";
791823
export * from "./agent-profile.js";
792824
export * from "./harness.js";
793825
export * from "./harness-capabilities.js";
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
/**
2+
* Interaction contract — the generalized human-in-the-loop primitive.
3+
*
4+
* An agent emits an `InteractionRequest`: a typed ask that carries a
5+
* self-describing `answerSpec` (the fields and types of a valid answer). A
6+
* human (or an automated policy) returns an `InteractionResponse` keyed by the
7+
* same `id`. This subsumes the original question/answer pair and extends it to
8+
* permissions, plans, and provider-specific asks.
9+
*
10+
* Design contract:
11+
* - The envelope is stable; `kind` is an OPEN label, so new ask types need no
12+
* change to this contract. Well-known kinds (see `InteractionKind`) get
13+
* richer rendering and platform handling; unknown kinds render generically
14+
* from `answerSpec` and still work end-to-end.
15+
* - `answerSpec` is a small closed set of flat field types, so any consumer can
16+
* render a form and validate a response without a general schema engine. This
17+
* mirrors MCP elicitation so MCP-originated asks map onto this 1:1.
18+
* - `default` + `timeoutMs`/`onTimeout` make unattended resolution explicit and
19+
* auditable, replacing blanket permission-bypass flags.
20+
*/
21+
22+
import { z } from "zod";
23+
24+
// =============================================================================
25+
// Answer specification — describes the shape of a valid answer.
26+
// =============================================================================
27+
28+
const FieldBase = {
29+
/** Stable key the answer is returned under in `InteractionResponse.data`. */
30+
name: z.string().min(1),
31+
/** Human-readable label for the form control. */
32+
label: z.string().min(1),
33+
/** Whether the answer must supply this field to be `accepted`. */
34+
required: z.boolean().optional(),
35+
};
36+
37+
export const InteractionFieldSchema = z.discriminatedUnion("type", [
38+
z.object({
39+
...FieldBase,
40+
type: z.literal("text"),
41+
multiline: z.boolean().optional(),
42+
placeholder: z.string().optional(),
43+
default: z.string().optional(),
44+
}),
45+
z.object({
46+
...FieldBase,
47+
type: z.literal("number"),
48+
min: z.number().optional(),
49+
max: z.number().optional(),
50+
default: z.number().optional(),
51+
}),
52+
z.object({
53+
...FieldBase,
54+
type: z.literal("boolean"),
55+
default: z.boolean().optional(),
56+
}),
57+
z.object({
58+
...FieldBase,
59+
type: z.literal("select"),
60+
options: z
61+
.array(
62+
z.object({
63+
value: z.string(),
64+
label: z.string(),
65+
description: z.string().optional(),
66+
}),
67+
)
68+
.min(1),
69+
/** When true the user may pick more than one option. */
70+
multi: z.boolean().optional(),
71+
default: z.array(z.string()).optional(),
72+
}),
73+
/** Like `text` but the value is sensitive (token/key) and must be masked. */
74+
z.object({
75+
...FieldBase,
76+
type: z.literal("secret"),
77+
placeholder: z.string().optional(),
78+
}),
79+
]);
80+
export type InteractionField = z.infer<typeof InteractionFieldSchema>;
81+
82+
export const InteractionAnswerSpecSchema = z.object({
83+
fields: z.array(InteractionFieldSchema),
84+
});
85+
export type InteractionAnswerSpec = z.infer<typeof InteractionAnswerSpecSchema>;
86+
87+
// =============================================================================
88+
// Subject — what the request is about (drives preview/permission UX).
89+
// =============================================================================
90+
91+
export const InteractionSubjectSchema = z.discriminatedUnion("type", [
92+
z.object({ type: z.literal("tool"), toolName: z.string(), input: z.unknown().optional() }),
93+
z.object({ type: z.literal("command"), command: z.string() }),
94+
z.object({ type: z.literal("file"), path: z.string(), preview: z.string().optional() }),
95+
z.object({ type: z.literal("resource"), uri: z.string() }),
96+
]);
97+
export type InteractionSubject = z.infer<typeof InteractionSubjectSchema>;
98+
99+
// =============================================================================
100+
// Outcome + resolution — the answer.
101+
// =============================================================================
102+
103+
export const InteractionOutcomeSchema = z.enum(["accepted", "declined", "cancelled"]);
104+
export type InteractionOutcome = z.infer<typeof InteractionOutcomeSchema>;
105+
106+
/** Field values keyed by `InteractionField.name`. Validated against `answerSpec`. */
107+
export const InteractionDataSchema = z.record(
108+
z.string(),
109+
z.union([z.string(), z.number(), z.boolean(), z.array(z.string())]),
110+
);
111+
export type InteractionData = z.infer<typeof InteractionDataSchema>;
112+
113+
export const InteractionResolutionSchema = z.object({
114+
outcome: InteractionOutcomeSchema,
115+
/** Present (and validated) only when `outcome === "accepted"`. */
116+
data: InteractionDataSchema.optional(),
117+
});
118+
export type InteractionResolution = z.infer<typeof InteractionResolutionSchema>;
119+
120+
// =============================================================================
121+
// The request envelope.
122+
// =============================================================================
123+
124+
export const InteractionRequestSchema = z.object({
125+
/** Correlation id; unique within a session. The response carries the same id. */
126+
id: z.string().min(1),
127+
/**
128+
* Open label for rendering, handling, and authorization. Well-known values:
129+
* `question` | `permission` | `plan`. Vendor extensions SHOULD namespace,
130+
* e.g. `x-pi.choose-extension`.
131+
*/
132+
kind: z.string().min(1),
133+
/** Short human-readable prompt. */
134+
title: z.string().min(1),
135+
/** Optional longer context (markdown). */
136+
body: z.string().optional(),
137+
subject: InteractionSubjectSchema.optional(),
138+
answerSpec: InteractionAnswerSpecSchema,
139+
/** Resolution applied when unattended or timed out — explicit, not a bypass flag. */
140+
default: InteractionResolutionSchema.optional(),
141+
/** Wait this long for a human before applying `onTimeout`. */
142+
timeoutMs: z.number().int().positive().optional(),
143+
/** On timeout: apply `default`, `fail` the turn, or keep `wait`ing. Default `wait`. */
144+
onTimeout: z.enum(["default", "fail", "wait"]).optional(),
145+
});
146+
export type InteractionRequest = z.infer<typeof InteractionRequestSchema>;
147+
148+
export const InteractionResponseSchema = InteractionResolutionSchema.extend({
149+
id: z.string().min(1),
150+
});
151+
export type InteractionResponse = z.infer<typeof InteractionResponseSchema>;
152+
153+
// =============================================================================
154+
// Well-known kinds + helpers.
155+
// =============================================================================
156+
157+
export const InteractionKind = {
158+
/** Agent asks the user to answer/choose. Answer = the chosen field values. */
159+
Question: "question",
160+
/** Agent requests approval to run a tool/command. Answer = a `PermissionGrant`. */
161+
Permission: "permission",
162+
/** Agent shares a plan/todo list for review/approval. */
163+
Plan: "plan",
164+
} as const;
165+
export type WellKnownInteractionKind =
166+
(typeof InteractionKind)[keyof typeof InteractionKind];
167+
168+
/** Field name carrying the grant on a `permission` interaction's response. */
169+
export const PERMISSION_GRANT_FIELD = "grant";
170+
/** Optional free-text field carrying the user's reason on a `permission` response. */
171+
export const PERMISSION_FEEDBACK_FIELD = "feedback";
172+
173+
/** Graduated permission decision — the value of the `grant` field. */
174+
export const PermissionGrantSchema = z.enum([
175+
"allow_once",
176+
"allow_session",
177+
"allow_always",
178+
"deny",
179+
]);
180+
export type PermissionGrant = z.infer<typeof PermissionGrantSchema>;
181+
182+
/** Build the answer spec for a `permission` interaction (graduated grant + feedback). */
183+
export function permissionAnswerSpec(opts?: { allowFeedback?: boolean }): InteractionAnswerSpec {
184+
const fields: InteractionField[] = [
185+
{
186+
type: "select",
187+
name: PERMISSION_GRANT_FIELD,
188+
label: "Decision",
189+
required: true,
190+
options: [
191+
{ value: "allow_once", label: "Allow once" },
192+
{ value: "allow_session", label: "Allow for this session" },
193+
{ value: "allow_always", label: "Always allow" },
194+
{ value: "deny", label: "Deny" },
195+
],
196+
},
197+
];
198+
if (opts?.allowFeedback !== false) {
199+
fields.push({
200+
type: "text",
201+
name: PERMISSION_FEEDBACK_FIELD,
202+
label: "Feedback (optional)",
203+
multiline: true,
204+
});
205+
}
206+
return { fields };
207+
}
208+
209+
/** Shape of one legacy question (kept for the back-compat shim). */
210+
export type LegacyQuestion = {
211+
question: string;
212+
options?: Array<{ label: string; description?: string }>;
213+
multiSelect?: boolean;
214+
};
215+
216+
/**
217+
* Build an answer spec from the legacy `question` event shape. Each question
218+
* becomes one select field (free text when it declares no options), so the old
219+
* question/answer path is expressible as a `question` interaction.
220+
*/
221+
export function questionAnswerSpec(questions: LegacyQuestion[]): InteractionAnswerSpec {
222+
const fields: InteractionField[] = questions.map((q, i) => {
223+
const name = `q${i}`;
224+
if (q.options && q.options.length > 0) {
225+
return {
226+
type: "select",
227+
name,
228+
label: q.question,
229+
required: true,
230+
multi: q.multiSelect === true,
231+
options: q.options.map((o) => ({ value: o.label, label: o.label, description: o.description })),
232+
};
233+
}
234+
return { type: "text", name, label: q.question, required: true };
235+
});
236+
return { fields };
237+
}
238+
239+
// =============================================================================
240+
// Generic validation — does `data` satisfy `answerSpec`? Fail-closed.
241+
// =============================================================================
242+
243+
export type InteractionValidation = { ok: true } | { ok: false; errors: string[] };
244+
245+
/**
246+
* Validate an accepted answer against its spec. Used by the broker before a
247+
* response reaches the adapter, so malformed answers are rejected centrally.
248+
*/
249+
export function validateInteractionAnswer(
250+
spec: InteractionAnswerSpec,
251+
data: InteractionData | undefined,
252+
): InteractionValidation {
253+
const errors: string[] = [];
254+
const d = data ?? {};
255+
for (const field of spec.fields) {
256+
const v = d[field.name];
257+
const present = v !== undefined && v !== null && !(typeof v === "string" && v === "");
258+
if (!present) {
259+
if (field.required) errors.push(`missing required field "${field.name}"`);
260+
continue;
261+
}
262+
switch (field.type) {
263+
case "text":
264+
case "secret":
265+
if (typeof v !== "string") errors.push(`field "${field.name}" must be a string`);
266+
break;
267+
case "number":
268+
if (typeof v !== "number") {
269+
errors.push(`field "${field.name}" must be a number`);
270+
} else {
271+
if (field.min !== undefined && v < field.min) errors.push(`field "${field.name}" below min ${field.min}`);
272+
if (field.max !== undefined && v > field.max) errors.push(`field "${field.name}" above max ${field.max}`);
273+
}
274+
break;
275+
case "boolean":
276+
if (typeof v !== "boolean") errors.push(`field "${field.name}" must be a boolean`);
277+
break;
278+
case "select": {
279+
if (!Array.isArray(v)) {
280+
errors.push(`field "${field.name}" must be an array of option values`);
281+
break;
282+
}
283+
if (!field.multi && v.length > 1) errors.push(`field "${field.name}" accepts a single value`);
284+
if (field.required && v.length === 0) errors.push(`field "${field.name}" requires a selection`);
285+
const allowed = new Set(field.options.map((o) => o.value));
286+
for (const choice of v) {
287+
if (!allowed.has(choice)) errors.push(`field "${field.name}" has invalid option "${choice}"`);
288+
}
289+
break;
290+
}
291+
}
292+
}
293+
return errors.length === 0 ? { ok: true } : { ok: false, errors };
294+
}

0 commit comments

Comments
 (0)