File size: 6,509 Bytes
eb3f11e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 | import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { Type } from "typebox";
import type { QuestionRequestQuestion } from "../../../packages/gateway-protocol/src/index.js";
import { ToolInputError } from "./common.js";
export const DEFAULT_ASK_USER_TIMEOUT_SECONDS = 900;
export const QUESTION_RPC_GRACE_MS = 10_000;
const MIN_ASK_USER_TIMEOUT_SECONDS = 30;
const MAX_ASK_USER_TIMEOUT_SECONDS = 3600;
const QUESTION_ID_PATTERN = /^[a-z][a-z0-9_]*$/;
export type NormalizedAskUserParams = {
questions: QuestionRequestQuestion[];
timeoutSeconds: number;
};
function readRequiredString(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new ToolInputError(`${label} must be a non-empty string`);
}
return value.trim();
}
function normalizeOption(value: unknown, questionIndex: number, optionIndex: number) {
const labelPrefix = `questions[${questionIndex}].options[${optionIndex}]`;
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new ToolInputError(`${labelPrefix} must be an object`);
}
const record = value as Record<string, unknown>;
const label = readRequiredString(record.label, `${labelPrefix}.label`);
if (label.length > 64) {
throw new ToolInputError(`${labelPrefix}.label must be at most 64 characters (use 1-5 words)`);
}
if (record.description !== undefined && typeof record.description !== "string") {
throw new ToolInputError(`${labelPrefix}.description must be a string`);
}
const description =
typeof record.description === "string" ? record.description.trim() : undefined;
return { label, ...(description ? { description } : {}) };
}
/** Validates and canonicalizes model-authored ask_user arguments. */
export function normalizeAskUserParams(value: unknown): NormalizedAskUserParams {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new ToolInputError("ask_user arguments must be an object");
}
const params = value as Record<string, unknown>;
if (
!Array.isArray(params.questions) ||
params.questions.length < 1 ||
params.questions.length > 3
) {
throw new ToolInputError("questions must contain 1 to 3 questions");
}
const ids = new Set<string>();
const questions = params.questions.map(
(questionValue, questionIndex): QuestionRequestQuestion => {
const prefix = `questions[${questionIndex}]`;
if (!questionValue || typeof questionValue !== "object" || Array.isArray(questionValue)) {
throw new ToolInputError(`${prefix} must be an object`);
}
const question = questionValue as Record<string, unknown>;
const id = readRequiredString(question.id, `${prefix}.id`);
if (!QUESTION_ID_PATTERN.test(id)) {
throw new ToolInputError(`${prefix}.id must be snake_case (for example, deploy_target)`);
}
if (ids.has(id)) {
throw new ToolInputError(`duplicate question id '${id}'`);
}
ids.add(id);
const header = truncateUtf16Safe(readRequiredString(question.header, `${prefix}.header`), 12);
const questionText = readRequiredString(question.question, `${prefix}.question`);
if (
!Array.isArray(question.options) ||
question.options.length < 2 ||
question.options.length > 4
) {
throw new ToolInputError(`${prefix}.options must contain 2 to 4 options`);
}
if (question.multiSelect !== undefined && typeof question.multiSelect !== "boolean") {
throw new ToolInputError(`${prefix}.multiSelect must be a boolean`);
}
return {
questionId: id,
header,
question: questionText,
options: question.options.map((option, optionIndex) =>
normalizeOption(option, questionIndex, optionIndex),
),
...(question.multiSelect === true ? { multiSelect: true } : {}),
isOther: true,
};
},
);
return { questions, timeoutSeconds: normalizeQuestionTimeoutSeconds(params.timeoutSeconds) };
}
/** Shared human-question wait contract, including credential entry and harness watchdogs. */
export function normalizeQuestionTimeoutSeconds(rawTimeoutSeconds: unknown): number {
if (
rawTimeoutSeconds !== undefined &&
(typeof rawTimeoutSeconds !== "number" ||
!Number.isFinite(rawTimeoutSeconds) ||
!Number.isInteger(rawTimeoutSeconds))
) {
throw new ToolInputError("timeoutSeconds must be an integer");
}
return Math.min(
MAX_ASK_USER_TIMEOUT_SECONDS,
Math.max(MIN_ASK_USER_TIMEOUT_SECONDS, rawTimeoutSeconds ?? DEFAULT_ASK_USER_TIMEOUT_SECONDS),
);
}
export function resolveQuestionTimeoutMs(rawTimeoutSeconds: unknown): number {
return normalizeQuestionTimeoutSeconds(rawTimeoutSeconds) * 1_000 + QUESTION_RPC_GRACE_MS;
}
export const AskUserToolSchema = Type.Object(
{
questions: Type.Array(
Type.Object(
{
id: Type.String({
minLength: 1,
pattern: "^[a-z][a-z0-9_]*$",
description: "Unique snake_case answer key.",
}),
header: Type.String({
minLength: 1,
description: "Short chip label; longer input is truncated to 12 characters.",
}),
question: Type.String({
minLength: 1,
description: "Single-sentence question only. Put all selectable choices in options.",
}),
options: Type.Array(
Type.Object(
{
label: Type.String({ minLength: 1 }),
description: Type.Optional(Type.String()),
},
{ additionalProperties: false },
),
{
minItems: 2,
maxItems: 4,
description:
"Every selectable choice. Put the recommended choice first; do not repeat choices only in the question text.",
},
),
multiSelect: Type.Optional(
Type.Boolean({
description: "True only when the user may choose several options at once.",
}),
),
},
{ additionalProperties: false },
),
{ minItems: 1, maxItems: 3 },
),
timeoutSeconds: Type.Optional(
Type.Integer({
description:
"Maximum human wait in seconds; default 900, clamped 30-3600. Earlier run cancellation or overall run timeout still applies.",
}),
),
},
{ additionalProperties: false },
);
|