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 },
);