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' ;
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' ,
required : [ 'question' , 'header' ] ,
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 ] ;
const questionType = q . type ? ? QuestionType . CHOICE ;
// 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 ) ;
}
}
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 ,
type : q . type ? ? QuestionType . CHOICE ,
} ) ) ;
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 > {
if ( this . confirmationOutcome === ToolConfirmationOutcome . Cancel ) {
return {
llmContent : 'User dismissed ask_user dialog without answering.' ,
returnDisplay : 'User dismissed dialog' ,
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 ;
const returnDisplay = hasAnswers
? ` **User answered:** \ n ${ answerEntries
. map ( ( [ index , answer ] ) = > {
const question = this . params . questions [ parseInt ( index , 10 ) ] ;
const category = question ? . header ? ? ` Q ${ index } ` ;
return ` ${ category } → ${ answer } ` ;
} )
.join(' \ n')} `
: 'User submitted without answering questions.' ;
return {
llmContent : JSON.stringify ( { answers : this.userAnswers } ) ,
returnDisplay ,
} ;
2026-01-22 12:12:13 -05:00
}
}