2026-02-02 22:30:03 -05:00
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
BaseDeclarativeTool ,
BaseToolInvocation ,
Kind ,
2026-03-18 16:00:26 -04:00
ToolConfirmationOutcome ,
2026-02-02 22:30:03 -05:00
type ToolConfirmationPayload ,
2026-03-18 16:00:26 -04:00
type ToolExitPlanModeConfirmationDetails ,
2026-02-02 22:30:03 -05:00
type ToolExitPlanModeConfirmationPayload ,
2026-03-18 16:00:26 -04:00
type ToolResult ,
2026-04-10 10:11:17 -07:00
type ExecuteOptions ,
2026-02-02 22:30:03 -05:00
} from './tools.js' ;
import type { MessageBus } from '../confirmation-bus/message-bus.js' ;
import path from 'node:path' ;
import type { Config } from '../config/config.js' ;
import { EXIT_PLAN_MODE_TOOL_NAME } from './tool-names.js' ;
2026-04-21 14:20:57 -04:00
import {
validatePlanPath ,
validatePlanContent ,
resolveAndValidatePlanPath ,
} from '../utils/planUtils.js' ;
2026-02-02 22:30:03 -05:00
import { ApprovalMode } from '../policy/types.js' ;
2026-02-05 18:46:34 -05:00
import { logPlanExecution } from '../telemetry/loggers.js' ;
import { PlanExecutionEvent } from '../telemetry/types.js' ;
2026-02-13 23:55:02 -05:00
import { getExitPlanModeDefinition } from './definitions/coreTools.js' ;
import { resolveToolDeclaration } from './definitions/resolver.js' ;
2026-02-24 14:31:41 -05:00
import { getPlanModeExitMessage } from '../utils/approvalModeUtils.js' ;
2026-02-02 22:30:03 -05:00
export interface ExitPlanModeParams {
2026-03-24 09:19:29 -04:00
plan_filename : string ;
2026-02-02 22:30:03 -05:00
}
export class ExitPlanModeTool extends BaseDeclarativeTool <
ExitPlanModeParams ,
ToolResult
> {
2026-03-02 17:30:50 -05:00
static readonly Name = EXIT_PLAN_MODE_TOOL_NAME ;
2026-02-02 22:30:03 -05:00
constructor (
private config : Config ,
messageBus : MessageBus ,
) {
2026-03-24 09:19:29 -04:00
const definition = getExitPlanModeDefinition ( ) ;
2026-02-02 22:30:03 -05:00
super (
2026-03-02 17:30:50 -05:00
ExitPlanModeTool . Name ,
2026-02-02 22:30:03 -05:00
'Exit Plan Mode' ,
2026-02-13 23:55:02 -05:00
definition . base . description ! ,
2026-02-02 22:30:03 -05:00
Kind . Plan ,
2026-02-13 23:55:02 -05:00
definition . base . parametersJsonSchema ,
2026-02-02 22:30:03 -05:00
messageBus ,
) ;
}
protected override validateToolParamValues (
params : ExitPlanModeParams ,
) : string | null {
2026-03-24 09:19:29 -04:00
if ( ! params . plan_filename || params . plan_filename . trim ( ) === '' ) {
return 'plan_filename is required.' ;
2026-02-02 22:30:03 -05:00
}
2026-04-21 14:20:57 -04:00
try {
resolveAndValidatePlanPath (
params . plan_filename ,
this . config . storage . getPlansDir ( ) ,
this . config . getProjectRoot ( ) ,
) ;
} catch ( e ) {
return e instanceof Error ? e.message : String ( e ) ;
2026-02-02 22:30:03 -05:00
}
return null ;
}
protected createInvocation (
params : ExitPlanModeParams ,
messageBus : MessageBus ,
toolName : string ,
toolDisplayName : string ,
) : ExitPlanModeInvocation {
return new ExitPlanModeInvocation (
params ,
messageBus ,
toolName ,
toolDisplayName ,
this . config ,
) ;
}
2026-02-13 23:55:02 -05:00
override getSchema ( modelId? : string ) {
2026-03-24 09:19:29 -04:00
return resolveToolDeclaration ( getExitPlanModeDefinition ( ) , modelId ) ;
2026-02-13 23:55:02 -05:00
}
2026-02-02 22:30:03 -05:00
}
export class ExitPlanModeInvocation extends BaseToolInvocation <
ExitPlanModeParams ,
ToolResult
> {
private confirmationOutcome : ToolConfirmationOutcome | null = null ;
private approvalPayload : ToolExitPlanModeConfirmationPayload | null = null ;
private planValidationError : string | null = null ;
constructor (
params : ExitPlanModeParams ,
messageBus : MessageBus ,
toolName : string ,
toolDisplayName : string ,
private config : Config ,
) {
super ( params , messageBus , toolName , toolDisplayName ) ;
}
override async shouldConfirmExecute (
abortSignal : AbortSignal ,
) : Promise < ToolExitPlanModeConfirmationDetails | false > {
const resolvedPlanPath = this . getResolvedPlanPath ( ) ;
const pathError = await validatePlanPath (
2026-03-24 09:19:29 -04:00
this . params . plan_filename ,
2026-02-19 17:47:08 -05:00
this . config . storage . getPlansDir ( ) ,
2026-04-21 14:20:57 -04:00
this . config . getProjectRoot ( ) ,
2026-02-02 22:30:03 -05:00
) ;
if ( pathError ) {
this . planValidationError = pathError ;
return false ;
}
const contentError = await validatePlanContent ( resolvedPlanPath ) ;
if ( contentError ) {
this . planValidationError = contentError ;
return false ;
}
const decision = await this . getMessageBusDecision ( abortSignal ) ;
2026-03-21 03:52:39 +00:00
if ( decision === 'deny' ) {
2026-02-02 22:30:03 -05:00
throw new Error (
` Tool execution for " ${
this . _toolDisplayName || this . _toolName
} " denied by policy. ` ,
) ;
}
2026-03-21 03:52:39 +00:00
if ( decision === 'allow' ) {
2026-02-02 22:30:03 -05:00
// If policy is allow, auto-approve with default settings and execute.
this . confirmationOutcome = ToolConfirmationOutcome . ProceedOnce ;
this . approvalPayload = {
approved : true ,
2026-03-18 16:00:26 -04:00
approvalMode : this.getAllowApprovalMode ( ) ,
2026-02-02 22:30:03 -05:00
} ;
return false ;
}
2026-03-21 03:52:39 +00:00
// decision is 'ask_user'
2026-02-02 22:30:03 -05:00
return {
type : 'exit_plan_mode' ,
title : 'Plan Approval' ,
planPath : resolvedPlanPath ,
onConfirm : async (
outcome : ToolConfirmationOutcome ,
payload? : ToolConfirmationPayload ,
) = > {
this . confirmationOutcome = outcome ;
if ( payload && 'approved' in payload ) {
this . approvalPayload = payload ;
}
} ,
} ;
}
getDescription ( ) : string {
2026-03-24 09:19:29 -04:00
return ` Requesting plan approval for: ${ path . join ( this . config . storage . getPlansDir ( ) , this . params . plan_filename ) } ` ;
2026-02-02 22:30:03 -05:00
}
/**
* Returns the resolved plan path.
* Note: Validation is done in validateToolParamValues, so this assumes the path is valid.
*/
private getResolvedPlanPath ( ) : string {
2026-04-21 14:20:57 -04:00
return resolveAndValidatePlanPath (
this . params . plan_filename ,
this . config . storage . getPlansDir ( ) ,
this . config . getProjectRoot ( ) ,
) ;
2026-02-02 22:30:03 -05:00
}
2026-04-10 10:11:17 -07:00
async execute ( { abortSignal : _signal } : ExecuteOptions ) : Promise < ToolResult > {
2026-02-02 22:30:03 -05:00
const resolvedPlanPath = this . getResolvedPlanPath ( ) ;
if ( this . planValidationError ) {
return {
llmContent : this.planValidationError ,
returnDisplay : 'Error: Invalid plan' ,
} ;
}
if ( this . confirmationOutcome === ToolConfirmationOutcome . Cancel ) {
return {
llmContent :
'User cancelled the plan approval dialog. The plan was not approved and you are still in Plan Mode.' ,
returnDisplay : 'Cancelled' ,
} ;
}
2026-03-12 07:43:40 -07:00
// When a user policy grants `allow` for exit_plan_mode, the scheduler
// skips the confirmation phase entirely and shouldConfirmExecute is never
2026-03-18 16:00:26 -04:00
// called, leaving approvalPayload null.
2026-03-12 07:43:40 -07:00
const payload = this . approvalPayload ? ? {
approved : true ,
2026-03-18 16:00:26 -04:00
approvalMode : this.getAllowApprovalMode ( ) ,
2026-03-12 07:43:40 -07:00
} ;
if ( payload . approved ) {
2026-02-02 22:30:03 -05:00
const newMode = payload . approvalMode ? ? ApprovalMode . DEFAULT ;
2026-02-24 14:31:41 -05:00
2026-03-18 16:00:26 -04:00
if ( newMode === ApprovalMode . PLAN ) {
2026-02-24 14:31:41 -05:00
throw new Error ( ` Unexpected approval mode: ${ newMode } ` ) ;
}
2026-02-02 22:30:03 -05:00
this . config . setApprovalMode ( newMode ) ;
2026-02-04 12:01:43 -05:00
this . config . setApprovedPlanPath ( resolvedPlanPath ) ;
2026-02-02 22:30:03 -05:00
2026-02-05 18:46:34 -05:00
logPlanExecution ( this . config , new PlanExecutionEvent ( newMode ) ) ;
2026-02-24 14:31:41 -05:00
const exitMessage = getPlanModeExitMessage ( newMode ) ;
2026-02-02 22:30:03 -05:00
return {
2026-05-26 16:42:53 +00:00
llmContent : ` ${ exitMessage } \ n \ nThe approved implementation plan is stored at: ${ resolvedPlanPath } \ nRead and follow the plan strictly during implementation. ` ,
2026-02-02 22:30:03 -05:00
returnDisplay : ` Plan approved: ${ resolvedPlanPath } ` ,
} ;
} else {
const feedback = payload ? . feedback ? . trim ( ) ;
if ( feedback ) {
return {
2026-05-26 16:42:53 +00:00
llmContent : ` Plan rejected. User feedback: ${ feedback } \ n \ nThe plan is stored at: ${ resolvedPlanPath } \ nRevise the plan based on the feedback. ` ,
2026-02-02 22:30:03 -05:00
returnDisplay : ` Feedback: ${ feedback } ` ,
} ;
} else {
return {
2026-05-26 16:42:53 +00:00
llmContent : ` Plan rejected. No feedback provided. \ n \ nThe plan is stored at: ${ resolvedPlanPath } \ nAsk the user for specific feedback on how to improve the plan. ` ,
2026-02-02 22:30:03 -05:00
returnDisplay : 'Rejected (no feedback)' ,
} ;
}
}
}
2026-03-18 16:00:26 -04:00
/**
* Determines the approval mode to switch to when plan mode is exited via a policy ALLOW.
* In non-interactive environments, this defaults to YOLO to allow automated execution.
*/
private getAllowApprovalMode ( ) : ApprovalMode {
if ( ! this . config . isInteractive ( ) ) {
// For non-interactive environment requires minimal user action, exit as YOLO mode for plan implementation.
return ApprovalMode . YOLO ;
}
// By default, YOLO mode in interactive environment cannot enter/exit plan mode.
// Always exit plan mode and move to default approval mode if exit_plan_mode tool is configured with allow decision.
return ApprovalMode . DEFAULT ;
}
2026-02-02 22:30:03 -05:00
}