feat: add trusted confirm and input forms
This commit is contained in:
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user