feat: add trusted confirm and input forms

This commit is contained in:
2026-08-03 16:00:19 +08:00
parent bddf15a2d2
commit 9312b09e76
11 changed files with 492 additions and 31 deletions
+88
View File
@@ -53,6 +53,30 @@ export type ActionGroup = {
actions: readonly ActionGroupAction[];
};
export type ConfirmDefinition = {
approve_label: string;
cancel_label: string;
};
export type InputFieldType = "text" | "textarea" | "number";
export type InputField = {
id: string;
label: string;
type: InputFieldType;
required: boolean;
placeholder?: string;
min_length?: number;
max_length?: number;
pattern?: string;
};
export type InputForm = {
fields: readonly InputField[];
submit_label: string;
cancel_label: string;
};
export type ToolCallRequest = {
call_id: string;
tool: ToolCallKind;
@@ -62,6 +86,10 @@ export type ToolCallRequest = {
data: JsonObject;
/** Present only for the trusted `choice` renderer introduced in M1-02. */
action_group?: ActionGroup;
/** Present only for the trusted `confirm` renderer introduced in M1-03. */
confirm?: ConfirmDefinition;
/** Present only for the trusted `input` renderer introduced in M1-03. */
form?: InputForm;
};
export type ToolCallFinalStatus = "completed" | "failed" | "cancelled" | "expired";
@@ -283,6 +311,7 @@ function validAppCallPayload(payload: JsonObject): boolean {
const TOOL_KINDS = new Set<ToolCallKind>(["choice", "confirm", "input"]);
const TOOL_FINAL_STATUSES = new Set<ToolCallFinalStatus>(["completed", "failed", "cancelled", "expired"]);
const ACTION_GROUP_MODES = new Set<ActionGroupMode>(["single-choice", "multi-choice", "button"]);
const INPUT_FIELD_TYPES = new Set<InputFieldType>(["text", "textarea", "number"]);
function optionalTimestamp(value: unknown): string | undefined {
return typeof value === "string" && !Number.isNaN(Date.parse(value)) ? value : undefined;
@@ -307,6 +336,59 @@ function parseActionGroup(value: unknown): ActionGroup | undefined {
return { mode, actions };
}
function boundedLabel(value: unknown, fallback: string): string | undefined {
const label = value === undefined ? fallback : text(value).trim();
return label && label.length <= 80 ? label : undefined;
}
function parseConfirm(value: unknown): ConfirmDefinition | undefined {
const candidate = object(value);
if (!candidate) return undefined;
const approve = boundedLabel(candidate.approve_label, "确认");
const cancel = boundedLabel(candidate.cancel_label, "取消");
return approve && cancel ? { approve_label: approve, cancel_label: cancel } : undefined;
}
function wholeNumber(value: unknown, maximum: number): number | undefined {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= maximum ? value : undefined;
}
function parseInputForm(value: unknown): InputForm | undefined {
const candidate = object(value);
if (!candidate || !Array.isArray(candidate.fields) || candidate.fields.length < 1 || candidate.fields.length > 8) return undefined;
const seen = new Set<string>();
const fields: InputField[] = [];
for (const value of candidate.fields) {
const field = object(value);
const id = field && identifier(field.id);
const label = field && text(field.label).trim();
const type = field ? text(field.type) : "";
const placeholder = field ? text(field.placeholder).trim() : "";
const minLength = field?.min_length === undefined ? undefined : wholeNumber(field.min_length, 10_000);
const maxLength = field?.max_length === undefined ? undefined : wholeNumber(field.max_length, 10_000);
const pattern = field ? text(field.pattern) : "";
if (!id || !label || label.length > 120 || !INPUT_FIELD_TYPES.has(type as InputFieldType) || seen.has(id) || placeholder.length > 200 || pattern.length > 256) return undefined;
if ((field?.min_length !== undefined && minLength === undefined) || (field?.max_length !== undefined && maxLength === undefined) || (minLength !== undefined && maxLength !== undefined && minLength > maxLength)) return undefined;
if (pattern) {
try { new RegExp(pattern, "u"); } catch { return undefined; }
}
seen.add(id);
fields.push({
id,
label,
type: type as InputFieldType,
required: field?.required === true,
...(placeholder ? { placeholder } : {}),
...(minLength !== undefined ? { min_length: minLength } : {}),
...(maxLength !== undefined ? { max_length: maxLength } : {}),
...(pattern ? { pattern } : {}),
});
}
const submit = boundedLabel(candidate.submit_label, "提交");
const cancel = boundedLabel(candidate.cancel_label, "取消");
return submit && cancel ? { fields, submit_label: submit, cancel_label: cancel } : undefined;
}
function parseToolCall(payload: JsonObject): ToolCallRequest | undefined {
const callID = identifier(payload.call_id);
const tool = text(payload.tool) as ToolCallKind;
@@ -316,7 +398,11 @@ function parseToolCall(payload: JsonObject): ToolCallRequest | undefined {
const data = payload.data === undefined ? {} : object(payload.data);
if (!data) return undefined;
const actionGroup = tool === "choice" ? parseActionGroup(data.action_group) : undefined;
const confirm = tool === "confirm" ? parseConfirm(data.confirm) : undefined;
const form = tool === "input" ? parseInputForm(data.form) : undefined;
if (tool === "choice" && !actionGroup) return undefined;
if (tool === "confirm" && !confirm) return undefined;
if (tool === "input" && !form) return undefined;
return {
call_id: callID,
tool,
@@ -325,6 +411,8 @@ function parseToolCall(payload: JsonObject): ToolCallRequest | undefined {
...(expiresAt ? { expires_at: expiresAt } : {}),
data,
...(actionGroup ? { action_group: actionGroup } : {}),
...(confirm ? { confirm } : {}),
...(form ? { form } : {}),
};
}