basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2025 Qwen4 * SPDX-License-Identifier: Apache-2.05 */6 7import type {8 ToolAskUserQuestionConfirmationDetails,9 ToolConfirmationPayload,10 ToolResult,11} from './tools.js';12import type { PermissionDecision } from '../permissions/types.js';13import {14 BaseDeclarativeTool,15 BaseToolInvocation,16 Kind,17 ToolConfirmationOutcome,18} from './tools.js';19import type { FunctionDeclaration } from '@google/genai';20import type { Config } from '../config/config.js';21import { ToolDisplayNames, ToolNames } from './tool-names.js';22import { CAP_ESCALATION_LABELS } from '../plan-gate/types.js';23import { createDebugLogger } from '../utils/debugLogger.js';24import { InputFormat } from '../output/types.js';25 26const debugLogger = createDebugLogger('ASK_USER_QUESTION');27 28function parseAnswerQuestionIndex(29 key: string,30 questionCount: number,31): number | undefined {32 const index = Number(key);33 if (34 !Number.isSafeInteger(index) ||35 index < 0 ||36 index >= questionCount ||37 String(index) !== key38 ) {39 return undefined;40 }41 return index;42}43 44export interface QuestionOption {45 label: string;46 description: string;47}48 49export interface Question {50 question: string;51 header: string;52 options: QuestionOption[];53 multiSelect?: boolean;54}55 56export interface AskUserQuestionParams {57 questions: Question[];58 metadata?: {59 source?: string;60 };61}62 63const askUserQuestionToolDescription = `Use this tool when you need to ask the user questions during execution. This allows you to:641. Gather user preferences or requirements652. Clarify ambiguous instructions663. Get decisions on implementation choices as you work674. Offer choices to the user about what direction to take.68 69Usage notes:70- Users will always be able to select "Other" to provide custom text input71- Use multiSelect: true to allow multiple answers to be selected for a question72- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label73 74Plan mode note: In plan mode, use this tool to clarify requirements or choose between approaches BEFORE finalizing your plan. Do NOT use this tool to ask "Is this plan ready?" or "Should I proceed?" - use ExitPlanMode for plan approval.75`;76 77const askUserQuestionToolSchemaData: FunctionDeclaration = {78 name: 'ask_user_question',79 description: askUserQuestionToolDescription,80 parametersJsonSchema: {81 $schema: 'https://json-schema.org/draft/2020-12/schema',82 type: 'object',83 properties: {84 questions: {85 description: 'Questions to ask the user (1-4 questions)',86 minItems: 1,87 maxItems: 4,88 type: 'array',89 items: {90 type: 'object',91 properties: {92 question: {93 description:94 'The complete question to ask the user. Should be clear, specific, and end with a question mark. Example: "Which library should we use for date formatting?" If multiSelect is true, phrase it accordingly, e.g. "Which features do you want to enable?"',95 type: 'string',96 },97 header: {98 description:99 'Very short label displayed as a chip/tag (max 12 chars). Examples: "Auth method", "Library", "Approach".',100 type: 'string',101 },102 options: {103 description:104 "The available choices for this question. Must have 2-4 options. Each option should be a distinct, mutually exclusive choice (unless multiSelect is enabled). There should be no 'Other' option, that will be provided automatically.",105 minItems: 2,106 maxItems: 4,107 type: 'array',108 items: {109 type: 'object',110 properties: {111 label: {112 description:113 'The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice.',114 type: 'string',115 },116 description: {117 description:118 'Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications.',119 type: 'string',120 },121 },122 required: ['label', 'description'],123 additionalProperties: false,124 },125 },126 multiSelect: {127 description:128 'Set to true to allow the user to select multiple options instead of just one. Use when choices are not mutually exclusive.',129 default: false,130 type: 'boolean',131 },132 },133 required: ['question', 'header', 'options'],134 additionalProperties: false,135 },136 },137 metadata: {138 description:139 'Optional metadata for tracking and analytics purposes. Not displayed to user.',140 type: 'object',141 properties: {142 source: {143 description:144 'Optional identifier for the source of this question (e.g., "remember" for /remember command). Used for analytics tracking.',145 type: 'string',146 },147 },148 additionalProperties: false,149 },150 },151 required: ['questions'],152 additionalProperties: false,153 },154};155 156class AskUserQuestionToolInvocation extends BaseToolInvocation<157 AskUserQuestionParams,158 ToolResult159> {160 private userAnswers: Record<string, string> = {};161 private wasAnswered = false;162 163 constructor(164 private readonly _config: Config,165 params: AskUserQuestionParams,166 ) {167 super(params);168 }169 170 getDescription(): string {171 const questionCount = this.params.questions.length;172 return `Ask user ${questionCount} question${questionCount > 1 ? 's' : ''}`;173 }174 175 /**176 * ask_user_question always requires user confirmation so the user can177 * provide answers. In non-interactive mode without ACP support, we skip178 * confirmation (and subsequently skip execution).179 */180 override async getDefaultPermission(): Promise<PermissionDecision> {181 const isAcpMode =182 this._config.getExperimentalZedIntegration() ||183 this._config.getInputFormat() === InputFormat.STREAM_JSON;184 185 if (!this._config.isInteractive() && !isAcpMode) {186 // Non-interactive + no ACP: skip entirely187 return 'allow';188 }189 return 'ask';190 }191 192 override async getConfirmationDetails(193 _abortSignal: AbortSignal,194 ): Promise<ToolAskUserQuestionConfirmationDetails> {195 const details: ToolAskUserQuestionConfirmationDetails = {196 type: 'ask_user_question',197 title: 'Please answer the following question(s):',198 questions: this.params.questions,199 metadata: this.params.metadata,200 onConfirm: async (201 outcome: ToolConfirmationOutcome,202 payload?: ToolConfirmationPayload,203 ) => {204 switch (outcome) {205 case ToolConfirmationOutcome.ProceedOnce:206 case ToolConfirmationOutcome.ProceedAlways:207 this.wasAnswered = true;208 this.userAnswers = payload?.answers ?? {};209 break;210 case ToolConfirmationOutcome.Cancel:211 this.wasAnswered = false;212 break;213 default:214 this.wasAnswered = true;215 this.userAnswers = payload?.answers ?? {};216 break;217 }218 },219 };220 221 return details;222 }223 224 async execute(_signal: AbortSignal): Promise<ToolResult> {225 try {226 // Check if we're in a mode that supports user interaction227 // ACP mode (VSCode extension, etc.) uses non-interactive mode but can still collect user input228 const isAcpMode =229 this._config.getExperimentalZedIntegration() ||230 this._config.getInputFormat() === InputFormat.STREAM_JSON;231 232 // In non-interactive mode without ACP support, we cannot collect user input233 if (!this._config.isInteractive() && !isAcpMode) {234 const errorMessage =235 'Cannot ask user questions in non-interactive mode without ACP support. Please run in interactive mode or enable ACP mode to use this tool.';236 return {237 llmContent: errorMessage,238 returnDisplay: errorMessage,239 };240 }241 242 if (!this.wasAnswered) {243 const cancellationMessage = 'User declined to answer the questions.';244 return {245 llmContent: cancellationMessage,246 returnDisplay: cancellationMessage,247 };248 }249 250 // Format the answers for LLM consumption251 const answersContent = Object.entries(this.userAnswers)252 .flatMap(([key, value]) => {253 const questionIndex = parseAnswerQuestionIndex(254 key,255 this.params.questions.length,256 );257 if (questionIndex === undefined) return [];258 const question = this.params.questions[questionIndex]!;259 return `**${question.header || `Question ${questionIndex + 1}`}**: ${value}`;260 })261 .join('\n');262 263 // ── Plan gate metadata side effects ──────────────────────────264 this.applyPlanGateMetadata();265 266 const messageBody =267 answersContent.length > 0268 ? answersContent269 : 'No valid answers were provided.';270 const llmMessage = `User has provided the following answers:\n\n${messageBody}`;271 const displayMessage = `User has provided the following answers:\n\n${messageBody}`;272 273 return {274 llmContent: llmMessage,275 returnDisplay: displayMessage,276 };277 } catch (error) {278 const errorMessage =279 error instanceof Error ? error.message : String(error);280 debugLogger.error(281 `[AskUserQuestionTool] Error executing ask_user_question: ${errorMessage}`,282 );283 284 const errorLlmContent = `Failed to process user answers: ${errorMessage}`;285 286 return {287 llmContent: errorLlmContent,288 returnDisplay: `Error processing answers: ${errorMessage}`,289 };290 }291 }292 293 /**294 * Updates Plan Approval Gate state based on the metadata.source field295 * and the user's answer. Only acts on recognized gate metadata sources.296 */297 private applyPlanGateMetadata(): void {298 const source = this.params.metadata?.source;299 if (!source) return;300 301 const gateState = this._config.getPlanGateState();302 if (!gateState) return;303 304 if (source === 'plan_gate_cap') {305 // Cap escalation: only honor when a cap escalation actually306 // occurred (prevents model from fabricating this metadata).307 if (!gateState.capEscalationPending) {308 debugLogger.warn(309 '[applyPlanGateMetadata] plan_gate_cap ignored: no cap escalation pending',310 );311 return;312 }313 314 // The first answer determines the next gate mode.315 // Match against the canonical labels from CAP_ESCALATION_LABELS.316 const firstAnswer = Object.values(this.userAnswers)[0] ?? '';317 318 if (firstAnswer === CAP_ESCALATION_LABELS.CONTINUE) {319 gateState.gateMode = 'uncapped';320 } else if (firstAnswer === CAP_ESCALATION_LABELS.APPROVE) {321 gateState.gateMode = 'user_override';322 } else {323 // Free-text / Other: user takes manual control324 gateState.gateMode = 'user_takeover';325 }326 gateState.capEscalationPending = false;327 } else if (source === 'plan_gate_needs_user') {328 // Only honor when the gate actually returned needs_user329 // (prevents model from fabricating this metadata).330 if (!gateState.needsUserPending) {331 debugLogger.warn(332 '[applyPlanGateMetadata] plan_gate_needs_user ignored: no needs_user pending',333 );334 return;335 }336 // User answered a gate-suggested question. Only reset the337 // review count when the gate actually asked for user input338 // (gateMode must still be active, not already overridden).339 if (340 gateState.gateMode === 'capped' ||341 gateState.gateMode === 'uncapped'342 ) {343 gateState.reviewCount = 0;344 }345 gateState.needsUserPending = false;346 }347 }348}349 350export class AskUserQuestionTool extends BaseDeclarativeTool<351 AskUserQuestionParams,352 ToolResult353> {354 static readonly Name: string = ToolNames.ASK_USER_QUESTION;355 356 constructor(private readonly config: Config) {357 super(358 AskUserQuestionTool.Name,359 ToolDisplayNames.ASK_USER_QUESTION,360 askUserQuestionToolDescription,361 Kind.Think,362 askUserQuestionToolSchemaData.parametersJsonSchema as Record<363 string,364 unknown365 >,366 true, // isOutputMarkdown367 false, // canUpdateOutput368 false, // shouldDefer — kept always-visible so the model reaches for the structured clarification UX instead of asking in plain prose369 );370 }371 372 override validateToolParams(params: AskUserQuestionParams): string | null {373 // Validate questions array374 if (!Array.isArray(params.questions)) {375 return 'Parameter "questions" must be an array.';376 }377 378 if (params.questions.length < 1 || params.questions.length > 4) {379 return 'Parameter "questions" must contain between 1 and 4 questions.';380 }381 382 // Validate individual questions383 for (let i = 0; i < params.questions.length; i++) {384 const question = params.questions[i];385 386 if (387 !question.question ||388 typeof question.question !== 'string' ||389 question.question.trim() === ''390 ) {391 return `Question ${i + 1}: "question" must be a non-empty string.`;392 }393 394 if (395 !question.header ||396 typeof question.header !== 'string' ||397 question.header.trim() === ''398 ) {399 return `Question ${i + 1}: "header" must be a non-empty string.`;400 }401 402 if (question.header.length > 12) {403 return `Question ${i + 1}: "header" must be 12 characters or less.`;404 }405 406 if (!Array.isArray(question.options)) {407 return `Question ${i + 1}: "options" must be an array.`;408 }409 410 if (question.options.length < 2 || question.options.length > 4) {411 return `Question ${i + 1}: "options" must contain between 2 and 4 options.`;412 }413 414 // Validate options415 for (let j = 0; j < question.options.length; j++) {416 const option = question.options[j];417 418 if (419 !option.label ||420 typeof option.label !== 'string' ||421 option.label.trim() === ''422 ) {423 return `Question ${i + 1}, Option ${j + 1}: "label" must be a non-empty string.`;424 }425 426 if (427 !option.description ||428 typeof option.description !== 'string' ||429 option.description.trim() === ''430 ) {431 return `Question ${i + 1}, Option ${j + 1}: "description" must be a non-empty string.`;432 }433 }434 435 if (436 question.multiSelect !== undefined &&437 typeof question.multiSelect !== 'boolean'438 ) {439 return `Question ${i + 1}: "multiSelect" must be a boolean.`;440 }441 }442 443 return null;444 }445 446 protected createInvocation(params: AskUserQuestionParams) {447 return new AskUserQuestionToolInvocation(this.config, params);448 }449}450 