CoolFace
Datasetpublic

basant307/AI_Governance_Project

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
0likes48downloads
askUserQuestion.ts450 linesDownload Raw Back to tools
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 
basant307/AI_Governance_Project · CoolFace