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 ,
type ToolCallConfirmationDetails ,
} from './tools.js' ;
import type { MessageBus } from '../confirmation-bus/message-bus.js' ;
import {
MessageBusType ,
QuestionType ,
type Question ,
type AskUserRequest ,
type AskUserResponse ,
} from '../confirmation-bus/types.js' ;
import { randomUUID } from 'node:crypto' ;
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' ,
maxLength : 12 ,
description :
'Very short label displayed as a chip/tag (max 12 chars). Examples: "Auth method", "Library", "Approach".' ,
} ,
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-01-27 13:30:44 -05:00
"Only applies when type='text'. Hint text shown in the input field." ,
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
> {
override async shouldConfirmExecute (
_abortSignal : AbortSignal ,
) : Promise < ToolCallConfirmationDetails | false > {
return false ;
}
getDescription ( ) : string {
return ` Asking user: ${ this . params . questions . map ( ( q ) = > q . question ) . join ( ', ' ) } ` ;
}
async execute ( signal : AbortSignal ) : Promise < ToolResult > {
const correlationId = randomUUID ( ) ;
const request : AskUserRequest = {
type : MessageBusType . ASK_USER_REQUEST ,
questions : this.params.questions.map ( ( q ) = > ( {
. . . q ,
type : q . type ? ? QuestionType . CHOICE ,
} ) ) ,
correlationId ,
} ;
return new Promise < ToolResult > ( ( resolve , reject ) = > {
const responseHandler = ( response : AskUserResponse ) : void = > {
if ( response . correlationId === correlationId ) {
cleanup ( ) ;
2026-01-27 13:30:44 -05:00
// Handle user cancellation
if ( response . cancelled ) {
resolve ( {
llmContent : 'User dismissed ask user dialog without answering.' ,
returnDisplay : 'User dismissed dialog' ,
} ) ;
return ;
}
2026-01-22 12:12:13 -05:00
// Build formatted key-value display
2026-01-27 13:30:44 -05:00
const answerEntries = Object . entries ( response . answers ) ;
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.' ;
2026-01-22 12:12:13 -05:00
resolve ( {
llmContent : JSON.stringify ( { answers : response.answers } ) ,
returnDisplay ,
} ) ;
}
} ;
const cleanup = ( ) = > {
if ( responseHandler ) {
this . messageBus . unsubscribe (
MessageBusType . ASK_USER_RESPONSE ,
responseHandler ,
) ;
}
signal . removeEventListener ( 'abort' , abortHandler ) ;
} ;
const abortHandler = ( ) = > {
cleanup ( ) ;
resolve ( {
llmContent : 'Tool execution cancelled by user.' ,
returnDisplay : 'Cancelled' ,
error : {
message : 'Cancelled' ,
} ,
} ) ;
} ;
if ( signal . aborted ) {
abortHandler ( ) ;
return ;
}
signal . addEventListener ( 'abort' , abortHandler ) ;
this . messageBus . subscribe (
MessageBusType . ASK_USER_RESPONSE ,
responseHandler ,
) ;
// Publish request
this . messageBus . publish ( request ) . catch ( ( err ) = > {
cleanup ( ) ;
reject ( err ) ;
} ) ;
} ) ;
}
}