import { PilotDeckToolRuntimeError } from "../protocol/errors.js";
import type {
PilotDeckToolDefinition,
PilotDeckToolExecutionOutput,
PilotDeckToolRuntimeContext,
} from "../protocol/types.js";
import type { PilotDeckToolValidationResult } from "../protocol/schema.js";
import { validateHtmlPreview } from "../elicitation/validateHtmlPreview.js";
import type {
PilotDeckElicitationChannel,
PilotDeckElicitationRequest,
} from "../elicitation/PilotDeckElicitationChannel.js";
export const ASK_USER_QUESTION_TOOL_NAME = "ask_user_question";
* Header chip width — mirrors legacy
* `ASK_USER_QUESTION_TOOL_CHIP_WIDTH = 12` (prompt.ts).
*/
export const ASK_USER_QUESTION_HEADER_MAX = 12;
const ASK_USER_QUESTION_DESCRIPTION =
"Use this tool when you need to ask the user one or more multiple-choice questions during execution. " +
"It is suitable for gathering preferences or requirements, clarifying ambiguous instructions, " +
"getting decisions on implementation choices, or offering the user concrete directions to choose from. " +
"Usage notes: provide 1-4 questions, each with 2-4 options; set multiSelect to true when a question " +
"should allow multiple selections; phrase the choices yourself instead of using this tool for open-ended " +
"free-form clarification. In plan mode, use ask_user_question to clarify requirements or choose between " +
"approaches before finalizing your plan. Do not use it to ask for plan approval; use exit_plan_mode for that.";
export type AskUserQuestionOption = {
label: string;
description: string;
preview?: string;
};
export type AskUserQuestionItem = {
question: string;
header: string;
options: AskUserQuestionOption[];
multiSelect?: boolean;
};
export type AskUserQuestionInput = {
questions: AskUserQuestionItem[];
answers?: Record<string, string | string[]>;
annotations?: Record<string, { preview?: string; notes?: string }>;
metadata?: { source?: string };
};
export type AskUserQuestionOutput = {
questions: AskUserQuestionItem[];
answers: Record<string, string | string[]>;
annotations?: Record<string, { preview?: string; notes?: string }>;
};
* Permission stage runs on the tool runtime; this is the moment we ask the
* host to surface the multiple-choice dialog. The actual dispatch happens
* via `runtimeContext.elicitation.askUser`.
*
* Behaviour alignment with `AskUserQuestionTool.tsx` (E1..E10 in §5.1.6):
* E1 schema: questions ≥ 1, ≤ 4 (legacy max).
* E2 each question.options ≥ 2, ≤ 4.
* E3 question texts unique within the call; option labels unique within
* each question (legacy `UNIQUENESS_REFINE`).
* E4 header.length ≤ ASK_USER_QUESTION_HEADER_MAX.
* E5 shouldDefer: true (legacy buildTool flag).
* E6 isReadOnly / isConcurrencySafe / requiresUserInteraction = true.
* E7 HTML preview validation (legacy `validateHtmlPreview`).
* E8 maxResultBytes = 100_000 (legacy `maxResultSizeChars`).
* E9 result mapping uses the legacy boilerplate format.
* E10 cancellation surfaces as `unsupported_tool` so the agent recovery
* loop can route back to the user via a fresh elicitation.
*/
export function createAskUserQuestionTool(): PilotDeckToolDefinition<
AskUserQuestionInput,
AskUserQuestionOutput
> {
return {
name: ASK_USER_QUESTION_TOOL_NAME,
aliases: ["AskUserQuestion"],
description: ASK_USER_QUESTION_DESCRIPTION,
kind: "session",
shouldDefer: true,
maxResultBytes: 100_000,
inputSchema: {
type: "object",
required: ["questions"],
additionalProperties: false,
properties: {
questions: {
type: "array",
minItems: 1,
maxItems: 4,
description:
"Questions to ask the user. Provide 1-4 multiple-choice questions in one batch.",
items: {
type: "object",
required: ["question", "header", "options"],
additionalProperties: false,
properties: {
question: {
type: "string",
description:
"The full question shown to the user. Keep it clear and specific; if multiSelect is true, phrase it as a multi-select question.",
},
header: {
type: "string",
maxLength: ASK_USER_QUESTION_HEADER_MAX,
description:
`Very short chip/tag label for the question (max ${ASK_USER_QUESTION_HEADER_MAX} chars).`,
},
options: {
type: "array",
minItems: 2,
maxItems: 4,
description:
"Available choices for this question. Provide 2-4 distinct options; they should be mutually exclusive unless multiSelect is true.",
items: {
type: "object",
required: ["label", "description"],
additionalProperties: false,
properties: {
label: {
type: "string",
description:
"Short display text for the option. This is what the user selects.",
},
description: {
type: "string",
description:
"Explanation of what the option means or what choosing it implies.",
},
preview: {
type: "string",
description:
"Optional host-specific preview content associated with this option. Hosts may surface it alongside the choice and may echo the selected preview back in annotations.",
},
},
},
},
multiSelect: {
type: "boolean",
description:
"Set to true to allow the user to select multiple options for this question instead of just one.",
},
},
},
},
answers: {
type: "object",
description:
"Optional pre-supplied answers keyed by question text. Values may be a single string or an array of strings for multi-select questions.",
},
annotations: {
type: "object",
description:
"Optional per-question annotation data keyed by question text, such as selected preview text or free-form user notes returned by the host.",
},
metadata: {
type: "object",
additionalProperties: false,
description:
"Optional metadata forwarded to the host with the elicitation request. Not displayed to the user.",
properties: {
source: {
type: "string",
description:
"Optional identifier describing why this question was asked, for host-side analytics or routing.",
},
},
},
},
},
isReadOnly: () => true,
isConcurrencySafe: () => true,
requiresUserInteraction: () => true,
validateInput: async (input): Promise<PilotDeckToolValidationResult> => {
if (!Array.isArray(input.questions) || input.questions.length < 1) {
return {
ok: false,
issues: [
{ path: "questions", code: "invalid_schema", message: "Provide 1-4 questions" },
],
};
}
if (input.questions.length > 4) {
return {
ok: false,
issues: [
{ path: "questions", code: "invalid_schema", message: "At most 4 questions allowed" },
],
};
}
for (const q of input.questions) {
if (!Array.isArray(q.options) || q.options.length < 2 || q.options.length > 4) {
return {
ok: false,
issues: [
{
path: "questions[].options",
code: "invalid_schema",
message: `Question "${q.question}" must have 2-4 options`,
},
],
};
}
}
const seenQuestions = new Set<string>();
for (const q of input.questions) {
if (seenQuestions.has(q.question)) {
return {
ok: false,
issues: [
{
path: "questions",
code: "invalid_schema",
message: `Question texts must be unique: "${q.question}"`,
},
],
};
}
seenQuestions.add(q.question);
if (q.header.length > ASK_USER_QUESTION_HEADER_MAX) {
return {
ok: false,
issues: [
{
path: "questions[].header",
code: "invalid_schema",
message: `header for "${q.question}" exceeds ${ASK_USER_QUESTION_HEADER_MAX} chars`,
},
],
};
}
const seenLabels = new Set<string>();
for (const opt of q.options) {
if (seenLabels.has(opt.label)) {
return {
ok: false,
issues: [
{
path: "questions[].options",
code: "invalid_schema",
message: `Option labels must be unique within question "${q.question}"`,
},
],
};
}
seenLabels.add(opt.label);
const htmlError = validateHtmlPreview(opt.preview);
if (htmlError !== null) {
return {
ok: false,
issues: [
{
path: "questions[].options[].preview",
code: "invalid_schema",
message: `Option "${opt.label}" in question "${q.question}": ${htmlError}`,
},
],
};
}
}
}
return { ok: true, input };
},
execute: async (input, context): Promise<PilotDeckToolExecutionOutput<AskUserQuestionOutput>> => {
const channel = (context as PilotDeckToolRuntimeContext & {
elicitation?: PilotDeckElicitationChannel;
}).elicitation;
if (!channel) {
throw new PilotDeckToolRuntimeError(
"unsupported_tool",
"ask_user_question requires a host elicitation channel (none registered).",
);
}
if (input.answers && Object.keys(input.answers).length > 0) {
const data: AskUserQuestionOutput = {
questions: input.questions,
answers: input.answers,
...(input.annotations && { annotations: input.annotations }),
};
return {
content: [
{ type: "text", text: formatAnswersForModel(input.answers, input.annotations) },
],
data,
};
}
const request: PilotDeckElicitationRequest = {
toolCallId: context.turnId,
toolName: ASK_USER_QUESTION_TOOL_NAME,
questions: input.questions,
...(input.metadata && { metadata: input.metadata }),
...(context.abortSignal && { signal: context.abortSignal }),
};
const answer = await channel.askUser(request);
if (answer.type === "cancelled") {
throw new PilotDeckToolRuntimeError(
"unsupported_tool",
`User declined to answer questions${answer.reason ? ` (${answer.reason})` : ""}`,
);
}
const data: AskUserQuestionOutput = {
questions: input.questions,
answers: answer.answers,
...(answer.annotations && { annotations: answer.annotations }),
};
return {
content: [
{ type: "text", text: formatAnswersForModel(answer.answers, answer.annotations) },
],
data,
};
},
};
}
* Reproduces legacy `mapToolResultToToolResultBlockParam` byte-for-byte
* (E9): "User has answered your questions: ...". The model uses this exact
* phrasing as a routing hint.
*/
function formatAnswersForModel(
answers: Record<string, string | string[]>,
annotations?: Record<string, { preview?: string; notes?: string }>,
): string {
const entries = Object.entries(answers).map(([questionText, answer]) => {
const annotation = annotations?.[questionText];
const display = Array.isArray(answer) ? answer.join(", ") : answer;
const parts = [`"${questionText}"="${display}"`];
if (annotation?.preview) {
parts.push(`selected preview:\n${annotation.preview}`);
}
if (annotation?.notes) {
parts.push(`user notes: ${annotation.notes}`);
}
return parts.join(" ");
});
return `User has answered your questions: ${entries.join(", ")}. You can now continue with the user's answers in mind.`;
}