2026-01-22 12:12:13 -05:00
/ * *
* @license
* Copyright 2026 Google LLC
* SPDX - License - Identifier : Apache - 2.0
* /
import {
BaseDeclarativeTool ,
BaseToolInvocation ,
type ToolResult ,
Kind ,
2026-01-30 13:32:21 -05:00
type ToolAskUserConfirmationDetails ,
type ToolConfirmationPayload ,
ToolConfirmationOutcome ,
2026-01-22 12:12:13 -05:00
} from './tools.js' ;
2026-02-12 16:49:07 -05:00
import { ToolErrorType } from './tool-error.js' ;
2026-01-22 12:12:13 -05:00
import type { MessageBus } from '../confirmation-bus/message-bus.js' ;
2026-01-30 13:32:21 -05:00
import { QuestionType , type Question } from '../confirmation-bus/types.js' ;
2026-01-27 13:30:44 -05:00
import { ASK_USER_TOOL_NAME , ASK_USER_DISPLAY_NAME } from './tool-names.js' ;
2026-01-22 12:12:13 -05:00
export interface AskUserParams {
questions : Question [ ] ;
}
export class AskUserTool extends BaseDeclarativeTool <
AskUserParams ,
ToolResult
> {
constructor ( messageBus : MessageBus ) {
super (
ASK_USER_TOOL_NAME ,
2026-01-27 13:30:44 -05:00
ASK_USER_DISPLAY_NAME ,
2026-01-22 12:12:13 -05:00
'Ask the user one or more questions to gather preferences, clarify requirements, or make decisions.' ,
2026-01-22 16:38:15 -05:00
Kind . Communicate ,
2026-01-22 12:12:13 -05:00
{
type : 'object' ,
required : [ 'questions' ] ,
properties : {
questions : {
type : 'array' ,
minItems : 1 ,
maxItems : 4 ,
items : {
type : 'object' ,
2026-02-13 10:03:52 -05:00
required : [ 'question' , 'header' , 'type' ] ,
2026-01-22 12:12:13 -05:00
properties : {
question : {
type : 'string' ,
description :
'The complete question to ask the user. Should be clear, specific, and end with a question mark.' ,
} ,
header : {
type : 'string' ,
2026-02-04 12:50:01 -05:00
maxLength : 16 ,
2026-01-22 12:12:13 -05:00
description :
2026-02-09 15:14:28 -05:00
'MUST be 16 characters or fewer or the call will fail. Very short label displayed as a chip/tag. Use abbreviations: "Auth" not "Authentication", "Config" not "Configuration". Examples: "Auth method", "Library", "Approach", "Database".' ,
2026-01-22 12:12:13 -05:00
} ,
type : {
type : 'string' ,
enum : [ 'choice' , 'text' , 'yesno' ] ,
2026-01-27 13:30:44 -05:00
default : 'choice' ,
2026-01-22 12:12:13 -05:00
description :
2026-01-27 13:30:44 -05:00
"Question type: 'choice' (default) for multiple-choice with options, 'text' for free-form input, 'yesno' for Yes/No confirmation." ,
2026-01-22 12:12:13 -05:00
} ,
options : {
type : 'array' ,
description :
2026-01-27 13:30:44 -05:00
"The selectable choices for 'choice' type questions. Provide 2-4 options. An 'Other' option is automatically added. Not needed for 'text' or 'yesno' types." ,
2026-01-22 12:12:13 -05:00
items : {
type : 'object' ,
required : [ 'label' , 'description' ] ,
properties : {
label : {
type : 'string' ,
description :
2026-01-27 13:30:44 -05:00
'The display text for this option (1-5 words). Example: "OAuth 2.0"' ,
2026-01-22 12:12:13 -05:00
} ,
description : {
type : 'string' ,
description :
2026-01-27 13:30:44 -05:00
'Brief explanation of this option. Example: "Industry standard, supports SSO"' ,
2026-01-22 12:12:13 -05:00
} ,
} ,
} ,
} ,
multiSelect : {
type : 'boolean' ,
description :
2026-01-27 13:30:44 -05:00
"Only applies when type='choice'. Set to true to allow selecting multiple options." ,
2026-01-22 12:12:13 -05:00
} ,
placeholder : {
type : 'string' ,
description :
2026-02-02 12:00:13 -05:00
"Hint text shown in the input field. For type='text', shown in the main input. For type='choice', shown in the 'Other' custom input." ,
2026-01-22 12:12:13 -05:00
} ,
} ,
} ,
} ,
} ,
} ,
messageBus ,
) ;
}
2026-01-27 13:30:44 -05:00
protected override validateToolParamValues (
params : AskUserParams ,
) : string | null {
if ( ! params . questions || params . questions . length === 0 ) {
return 'At least one question is required.' ;
}
for ( let i = 0 ; i < params . questions . length ; i ++ ) {
const q = params . questions [ i ] ;
2026-02-13 10:03:52 -05:00
const questionType = q . type ;
2026-01-27 13:30:44 -05:00
// Validate that 'choice' type has options
if ( questionType === QuestionType . CHOICE ) {
if ( ! q . options || q . options . length < 2 ) {
return ` Question ${ i + 1 } : type='choice' requires 'options' array with 2-4 items. ` ;
}
if ( q . options . length > 4 ) {
return ` Question ${ i + 1 } : 'options' array must have at most 4 items. ` ;
}
}
// Validate option structure if provided
if ( q . options ) {
for ( let j = 0 ; j < q . options . length ; j ++ ) {
const opt = q . options [ j ] ;
if (
! opt . label ||
typeof opt . label !== 'string' ||
! opt . label . trim ( )
) {
return ` Question ${ i + 1 } , option ${ j + 1 } : 'label' is required and must be a non-empty string. ` ;
}
if (
opt . description === undefined ||
typeof opt . description !== 'string'
) {
return ` Question ${ i + 1 } , option ${ j + 1 } : 'description' is required and must be a string. ` ;
}
}
}
}
return null ;
}
2026-01-22 12:12:13 -05:00
protected createInvocation (
params : AskUserParams ,
messageBus : MessageBus ,
toolName : string ,
toolDisplayName : string ,
) : AskUserInvocation {
return new AskUserInvocation ( params , messageBus , toolName , toolDisplayName ) ;
}
2026-02-12 16:49:07 -05:00
override async validateBuildAndExecute (
params : AskUserParams ,
abortSignal : AbortSignal ,
) : Promise < ToolResult > {
const result = await super . validateBuildAndExecute ( params , abortSignal ) ;
if (
result . error &&
result . error . type === ToolErrorType . INVALID_TOOL_PARAMS
) {
return {
. . . result ,
returnDisplay : '' ,
} ;
}
return result ;
}
2026-01-22 12:12:13 -05:00
}
export class AskUserInvocation extends BaseToolInvocation <
AskUserParams ,
ToolResult
> {
2026-01-30 13:32:21 -05:00
private confirmationOutcome : ToolConfirmationOutcome | null = null ;
private userAnswers : { [ questionIndex : string ] : string } = { } ;
2026-01-22 12:12:13 -05:00
override async shouldConfirmExecute (
_abortSignal : AbortSignal ,
2026-01-30 13:32:21 -05:00
) : Promise < ToolAskUserConfirmationDetails | false > {
const normalizedQuestions = this . params . questions . map ( ( q ) = > ( {
. . . q ,
2026-02-13 10:03:52 -05:00
type : q . type ,
2026-01-30 13:32:21 -05:00
} ) ) ;
return {
type : 'ask_user' ,
title : 'Ask User' ,
questions : normalizedQuestions ,
onConfirm : async (
outcome : ToolConfirmationOutcome ,
payload? : ToolConfirmationPayload ,
) = > {
this . confirmationOutcome = outcome ;
2026-01-30 14:51:45 -05:00
if ( payload && 'answers' in payload ) {
2026-01-30 13:32:21 -05:00
this . userAnswers = payload . answers ;
}
} ,
} ;
2026-01-22 12:12:13 -05:00
}
getDescription ( ) : string {
return ` Asking user: ${ this . params . questions . map ( ( q ) = > q . question ) . join ( ', ' ) } ` ;
}
2026-01-30 13:32:21 -05:00
async execute ( _signal : AbortSignal ) : Promise < ToolResult > {
2026-02-13 10:03:52 -05:00
const questionTypes = this . params . questions . map ( ( q ) = > q . type ) ;
2026-02-12 12:46:59 -05:00
2026-01-30 13:32:21 -05:00
if ( this . confirmationOutcome === ToolConfirmationOutcome . Cancel ) {
return {
llmContent : 'User dismissed ask_user dialog without answering.' ,
returnDisplay : 'User dismissed dialog' ,
2026-02-12 12:46:59 -05:00
data : {
ask_user : {
question_types : questionTypes ,
dismissed : true ,
} ,
} ,
2026-01-22 12:12:13 -05:00
} ;
2026-01-30 13:32:21 -05:00
}
2026-01-22 12:12:13 -05:00
2026-01-30 13:32:21 -05:00
const answerEntries = Object . entries ( this . userAnswers ) ;
const hasAnswers = answerEntries . length > 0 ;
2026-02-12 12:46:59 -05:00
const metrics : Record < string , unknown > = {
ask_user : {
question_types : questionTypes ,
dismissed : false ,
empty_submission : ! hasAnswers ,
answer_count : answerEntries.length ,
} ,
} ;
2026-01-30 13:32:21 -05:00
const returnDisplay = hasAnswers
? ` **User answered:** \ n ${ answerEntries
. map ( ( [ index , answer ] ) = > {
const question = this . params . questions [ parseInt ( index , 10 ) ] ;
const category = question ? . header ? ? ` Q ${ index } ` ;
2026-02-11 09:14:53 -05:00
const prefix = ` ${ category } → ` ;
const indent = ' ' . repeat ( prefix . length ) ;
const lines = answer . split ( '\n' ) ;
return prefix + lines . join ( '\n' + indent ) ;
2026-01-30 13:32:21 -05:00
} )
. join ( '\n' ) } `
: 'User submitted without answering questions.' ;
return {
llmContent : JSON.stringify ( { answers : this.userAnswers } ) ,
returnDisplay ,
2026-02-12 12:46:59 -05:00
data : metrics ,
2026-01-30 13:32:21 -05:00
} ;
2026-01-22 12:12:13 -05:00
}
}
2026-02-12 16:49:07 -05:00
/ * *
* Returns true if the tool name and status correspond to a completed 'Ask User' tool call .
* /
export function isCompletedAskUserTool ( name : string , status : string ) : boolean {
return (
name === ASK_USER_DISPLAY_NAME &&
[ 'Success' , 'Error' , 'Canceled' ] . includes ( status )
) ;
}