basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2025 Qwen4 * SPDX-License-Identifier: Apache-2.05 */6 7import type {8 ToolCallConfirmationDetails,9 ToolPlanConfirmationDetails,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 { ApprovalMode } from '../config/config.js';22import { ToolDisplayNames, ToolNames } from './tool-names.js';23import { isAutonomousPrePlanMode } from '../plan-gate/state.js';24import {25 runPlanApprovalGate,26 formatBlockedResponse,27 formatNeedsUserResponse,28 formatCapEscalationResponse,29 formatApprovedNotes,30} from '../plan-gate/planApprovalGate.js';31import type { EvidenceBundle } from '../plan-gate/types.js';32import { createDebugLogger } from '../utils/debugLogger.js';33import {34 buildSubagentPlanToolBlockedResult,35 isPlanRequiredTeammateContext,36 isPlanLifecycleToolUnavailableInSubagent,37} from '../agents/runtime/subagent-plan-tool-policy.js';38import { getTeammateContext } from '../agents/team/identity.js';39import type { TeamPlanApprovalDecision } from '../agents/team/TeamManager.js';40 41const debugLogger = createDebugLogger('EXIT_PLAN_MODE');42 43export interface ExitPlanModeParams {44 plan: string;45 originalRequest?: string;46 researchSummary?: string;47 resolutionSummary?: string;48}49 50const exitPlanModeToolDescription = `Use this tool when you are in plan mode and have finished presenting your plan and are ready to code. This will prompt the user to exit plan mode.51 52## When to Use This Tool53IMPORTANT: Only use this tool when the task requires planning the implementation steps of a task that requires writing code. For research tasks where you're gathering information, searching files, reading files or in general trying to understand the codebase - do NOT use this tool.54 55## Before Using This Tool56Ensure your plan is complete and unambiguous:57- If you have unresolved questions about requirements or approach, use AskUserQuestion first (in earlier phases)58- The plan parameter MUST contain your actual plan content — empty strings will be rejected59- Once your plan is finalized, use THIS tool to request approval60 61**Important:** Do NOT use AskUserQuestion to ask "Is this plan okay?" or "Should I proceed?" - that's exactly what THIS tool does. ExitPlanMode inherently requests user approval of your plan.62 63## Examples641. Initial task: "Search for and understand the implementation of vim mode in the codebase" - Do not use the exit plan mode tool because you are not planning the implementation steps of a task.652. Initial task: "Help me implement yank mode for vim" - Use the exit plan mode tool after you have finished planning the implementation steps of the task.663. Initial task: "Add a new feature to handle user authentication" - If unsure about auth method (OAuth, JWT, etc.), use AskUserQuestion first, then use exit plan mode tool after clarifying the approach.67`;68 69const exitPlanModeToolSchemaData: FunctionDeclaration = {70 name: 'exit_plan_mode',71 description: exitPlanModeToolDescription,72 parametersJsonSchema: {73 type: 'object',74 properties: {75 plan: {76 type: 'string',77 description:78 'The plan you came up with, that you want to run by the user for approval. Supports markdown. The plan should be pretty concise. Must contain your actual plan content — empty strings will be rejected.',79 },80 originalRequest: {81 type: 'string',82 description:83 'The original user request that prompted this plan. Restate it faithfully — it is the primary input for the plan approval gate.',84 },85 researchSummary: {86 type: 'string',87 description:88 'A brief summary of the investigation and key findings gathered during plan mode, including important file paths, symbols, and constraints discovered.',89 },90 resolutionSummary: {91 type: 'string',92 description:93 'When re-submitting after a gate review blocked the plan, include a summary referencing each finding id (e.g. GF-1) and how you addressed it.',94 },95 },96 required: ['plan'],97 additionalProperties: false,98 $schema: 'http://json-schema.org/draft-07/schema#',99 },100};101 102class ExitPlanModeToolInvocation extends BaseToolInvocation<103 ExitPlanModeParams,104 ToolResult105> {106 private wasApproved = false;107 108 constructor(109 private readonly config: Config,110 params: ExitPlanModeParams,111 ) {112 super(params);113 }114 115 getDescription(): string {116 return 'Plan:';117 }118 119 /**120 * The Plan Approval Gate auto-approves (runs inside execute(), no user prompt)121 * only when the model entered plan mode itself via enter_plan_mode while the122 * session was AUTO/YOLO — an autonomous flow that should not be interrupted.123 *124 * When the user entered plan mode explicitly (Shift+Tab, /plan, the dialog),125 * the confirmation UI always handles approval, even if prePlanMode happens to126 * be AUTO/YOLO. Note the Shift+Tab cycle order (…→auto→yolo→plan) means a127 * manual entry ALWAYS lands with prePlanMode === 'yolo', so prePlanMode alone128 * cannot distinguish the two cases — `enteredByModel` is the discriminator129 * (issue #5574).130 */131 override async getDefaultPermission(): Promise<PermissionDecision> {132 if (isPlanRequiredTeammateContext()) {133 return 'allow';134 }135 if (isPlanLifecycleToolUnavailableInSubagent(ToolNames.EXIT_PLAN_MODE)) {136 // Avoid showing an approval UI for a subagent-only rejection; execute()137 // still returns before saving the plan or changing approval mode.138 return 'allow';139 }140 141 const prePlanMode = this.config.getPrePlanMode();142 const gateState = this.config.getPlanGateState();143 if (144 isAutonomousPrePlanMode(prePlanMode) &&145 gateState &&146 gateState.enteredByModel &&147 gateState.gateMode !== 'user_takeover'148 ) {149 return 'allow';150 }151 return 'ask';152 }153 154 override async getConfirmationDetails(155 abortSignal: AbortSignal,156 ): Promise<ToolCallConfirmationDetails> {157 if (isPlanRequiredTeammateContext()) {158 return super.getConfirmationDetails(abortSignal);159 }160 if (isPlanLifecycleToolUnavailableInSubagent(ToolNames.EXIT_PLAN_MODE)) {161 return super.getConfirmationDetails(abortSignal);162 }163 164 const prePlanMode = this.config.getPrePlanMode();165 const details: ToolPlanConfirmationDetails = {166 type: 'plan',167 title: 'Would you like to proceed?',168 plan: this.params.plan,169 prePlanMode,170 onConfirm: async (outcome: ToolConfirmationOutcome) => {171 switch (outcome) {172 case ToolConfirmationOutcome.RestorePrevious:173 this.wasApproved = true;174 this.setApprovalModeSafely(prePlanMode);175 break;176 case ToolConfirmationOutcome.ProceedAlways:177 this.wasApproved = true;178 this.setApprovalModeSafely(ApprovalMode.AUTO_EDIT);179 break;180 case ToolConfirmationOutcome.ProceedOnce:181 this.wasApproved = true;182 this.setApprovalModeSafely(ApprovalMode.DEFAULT);183 break;184 case ToolConfirmationOutcome.Cancel:185 this.wasApproved = false;186 this.setApprovalModeSafely(ApprovalMode.PLAN);187 break;188 default:189 this.wasApproved = true;190 this.setApprovalModeSafely(ApprovalMode.DEFAULT);191 break;192 }193 },194 };195 196 return details;197 }198 199 private setApprovalModeSafely(mode: ApprovalMode): string | undefined {200 try {201 this.config.setApprovalMode(mode);202 return undefined;203 } catch (error) {204 const errorMessage =205 error instanceof Error ? error.message : String(error);206 debugLogger.error(207 `[ExitPlanModeTool] Failed to set approval mode to "${mode}": ${errorMessage}`,208 );209 return errorMessage;210 }211 }212 213 private buildRejectedGateDisplay(214 message: string,215 plan: string,216 details: string,217 ): ToolResult['returnDisplay'] {218 return {219 type: 'plan_summary',220 message,221 plan: `${plan.trimEnd()}\n\n---\n\n${details}`,222 rejected: true,223 };224 }225 226 async execute(signal: AbortSignal): Promise<ToolResult> {227 if (isPlanLifecycleToolUnavailableInSubagent(ToolNames.EXIT_PLAN_MODE)) {228 return buildSubagentPlanToolBlockedResult(229 ToolNames.EXIT_PLAN_MODE,230 'ExitPlanModeTool',231 debugLogger,232 );233 }234 235 const { plan, originalRequest, researchSummary, resolutionSummary } =236 this.params;237 if (isPlanRequiredTeammateContext()) {238 return this.executePlanRequiredTeammate(239 plan,240 originalRequest,241 researchSummary,242 signal,243 );244 }245 const prePlanMode = this.config.getPrePlanMode();246 const gateState = this.config.getPlanGateState();247 248 try {249 // ── Path A: user_override from cap escalation ──────────────250 if (gateState?.gateMode === 'user_override') {251 return this.approveAndRestore(plan, prePlanMode, 'Gate user override');252 }253 254 // ── Path B: AUTO/YOLO gate path (model-initiated, no takeover) ──255 if (256 isAutonomousPrePlanMode(prePlanMode) &&257 gateState &&258 gateState.enteredByModel &&259 gateState.gateMode !== 'user_takeover'260 ) {261 // Update the gate state with the latest resolution summary262 if (resolutionSummary) {263 gateState.lastResolutionSummary = resolutionSummary;264 }265 266 const bundle: EvidenceBundle = {267 originalRequest:268 originalRequest ||269 '(original request not provided by model — review the plan on its own merits)',270 plan,271 researchSummary,272 resolutionSummary: gateState.lastResolutionSummary,273 lastFindings:274 gateState.lastFindings.length > 0275 ? gateState.lastFindings276 : undefined,277 };278 279 const decision = await runPlanApprovalGate(this.config, bundle, signal);280 281 // After the async gate call, verify the user hasn't toggled out282 // of plan mode mid-gate (e.g. via Shift+Tab).283 const currentGateState = this.config.getPlanGateState();284 if (285 this.config.getApprovalMode() !== ApprovalMode.PLAN ||286 !currentGateState ||287 currentGateState.entryId !== gateState.entryId288 ) {289 return {290 llmContent:291 'Plan mode was exited while the gate was running. No action taken.',292 returnDisplay: 'Plan mode exited during gate review.',293 };294 }295 296 // Re-read prePlanMode after the async gate in case it was updated297 // (e.g. config reload) while the gate was running.298 const currentPrePlanMode = this.config.getPrePlanMode();299 300 switch (decision.kind) {301 case 'approved': {302 const notes = decision.nonBlockingFindings303 ? formatApprovedNotes(decision.nonBlockingFindings)304 : '';305 return this.approveAndRestore(306 plan,307 currentPrePlanMode,308 'Gate approved' + (notes ? `\n\n${notes}` : ''),309 );310 }311 case 'blocked': {312 const llmContent = formatBlockedResponse(decision);313 const message = `Plan gate: blocked (${decision.findings.length} finding(s))`;314 return {315 llmContent,316 returnDisplay: this.buildRejectedGateDisplay(317 message,318 plan,319 llmContent,320 ),321 };322 }323 case 'needs_user': {324 gateState.needsUserPending = true;325 const llmContent = formatNeedsUserResponse(decision);326 const message = `Plan gate: needs user input (${decision.questions.length} question(s))`;327 return {328 llmContent,329 returnDisplay: this.buildRejectedGateDisplay(330 message,331 plan,332 llmContent,333 ),334 };335 }336 case 'cap_escalation': {337 gateState.capEscalationPending = true;338 const llmContent = formatCapEscalationResponse(decision);339 const message = `Plan gate: cap reached with ${decision.blockingFindings.length} blocking finding(s)`;340 return {341 llmContent,342 returnDisplay: this.buildRejectedGateDisplay(343 message,344 plan,345 llmContent,346 ),347 };348 }349 case 'unavailable': {350 // Gate is broken — stay in PLAN mode but hand control to the user351 // so the next exit_plan_mode call shows the normal confirmation352 // dialog and requires explicit approval before execution.353 gateState.gateMode = 'user_takeover';354 debugLogger.warn(355 `Gate unavailable, requiring user approval in PLAN mode: ${decision.reason}`,356 );357 return this.fallbackToUserDecision(plan);358 }359 default: {360 const _exhaustive: never = decision;361 return {362 llmContent: `Unexpected gate decision: ${JSON.stringify(_exhaustive)}`,363 returnDisplay: 'Unexpected gate decision',364 };365 }366 }367 }368 369 // ── Path C: normal user confirmation path ──────────────────370 // Guard: if we somehow reached here without being in plan mode371 // (e.g. user toggled mode externally), report it accurately.372 if (373 this.config.getApprovalMode() !== ApprovalMode.PLAN &&374 !this.wasApproved375 ) {376 return {377 llmContent: 'Not in plan mode — no action taken.',378 returnDisplay: 'Not in plan mode.',379 };380 }381 382 // onConfirm already set the approval mode (PLAN -> target), so we383 // must NOT touch it here — only save the plan and return the result.384 if (!this.wasApproved) {385 const rejectionMessage =386 'Plan execution was not approved. Remaining in plan mode.';387 return {388 llmContent: rejectionMessage,389 returnDisplay: rejectionMessage,390 };391 }392 393 // Save plan to disk (mode was already set by onConfirm)394 try {395 this.config.savePlan(plan);396 } catch (error) {397 debugLogger.warn(398 `[ExitPlanModeTool] Failed to save plan to disk: ${error instanceof Error ? error.message : String(error)}`,399 );400 }401 402 const llmMessage =403 'User approved. You can now start coding. Start with updating your todo list if applicable.';404 return {405 llmContent: llmMessage,406 returnDisplay: {407 type: 'plan_summary',408 message: 'User approved.',409 plan,410 },411 };412 } catch (error) {413 const errorMessage =414 error instanceof Error ? error.message : String(error);415 debugLogger.error(416 `[ExitPlanModeTool] Error executing exit_plan_mode: ${errorMessage}`,417 );418 419 const errorLlmContent = `Failed to present plan: ${errorMessage}`;420 421 return {422 llmContent: errorLlmContent,423 returnDisplay: `Error presenting plan: ${errorMessage}`,424 };425 }426 }427 428 private async executePlanRequiredTeammate(429 plan: string,430 originalRequest: string | undefined,431 researchSummary: string | undefined,432 signal: AbortSignal,433 ): Promise<ToolResult> {434 if (this.config.getApprovalMode() !== ApprovalMode.PLAN) {435 return {436 llmContent: 'Not in plan mode — no action taken.',437 returnDisplay: 'Not in plan mode.',438 };439 }440 441 const teammate = getTeammateContext();442 const manager = this.config.getTeamManager();443 if (!teammate || !manager) {444 const message =445 'Plan-required teammate approval is unavailable in this context.';446 return {447 llmContent: message,448 returnDisplay: message,449 error: { message },450 };451 }452 453 let decision: TeamPlanApprovalDecision;454 try {455 decision = await manager.requestPlanApproval({456 teammateName: teammate.agentName,457 plan,458 originalRequest,459 researchSummary,460 signal,461 });462 } catch (error) {463 const message = error instanceof Error ? error.message : String(error);464 return {465 llmContent: `Failed to request leader plan approval: ${message}`,466 returnDisplay: `Leader plan approval failed: ${message}`,467 error: { message },468 };469 }470 471 if (decision.action === 'reject') {472 const feedback = decision.message473 ? `\n\nLeader feedback:\n${decision.message}`474 : '';475 const llmContent =476 'Leader rejected the plan. Revise the plan based on the feedback and call exit_plan_mode again.' +477 feedback;478 return {479 llmContent,480 returnDisplay: this.buildRejectedGateDisplay(481 'Leader rejected the plan.',482 plan,483 llmContent,484 ),485 };486 }487 488 const modeError = this.setApprovalModeSafely(decision.targetMode);489 if (modeError) {490 const message = `Leader approved the plan, but failed to switch this teammate to ${decision.targetMode}: ${modeError}`;491 return {492 llmContent: `${message}. Stay in plan mode and report this failure to the leader.`,493 returnDisplay: message,494 error: { message },495 };496 }497 498 try {499 this.config.savePlan(plan);500 } catch (error) {501 debugLogger.warn(502 `[ExitPlanModeTool] Failed to save plan to disk: ${error instanceof Error ? error.message : String(error)}`,503 );504 }505 506 const feedback = decision.message507 ? ` Leader note: ${decision.message}`508 : '';509 return {510 llmContent: `Leader approved.${feedback} You can now start coding. Start with updating your todo list if applicable.`,511 returnDisplay: {512 type: 'plan_summary',513 message: 'Leader approved.',514 plan,515 },516 };517 }518 519 private approveAndRestore(520 plan: string,521 targetMode: ApprovalMode,522 context: string,523 ): ToolResult {524 // Persist the approved plan to disk525 try {526 this.config.savePlan(plan);527 } catch (error) {528 debugLogger.warn(529 `[ExitPlanModeTool] Failed to save plan to disk: ${error instanceof Error ? error.message : String(error)}`,530 );531 }532 533 // Restore the pre-plan approval mode (this also clears gate state534 // via setApprovalMode's PLAN→non-PLAN transition).535 this.setApprovalModeSafely(targetMode);536 537 const llmMessage = `${context}. You can now start coding. Start with updating your todo list if applicable.`;538 const displayMessage = `${context}.`;539 540 return {541 llmContent: llmMessage,542 returnDisplay: {543 type: 'plan_summary',544 message: displayMessage,545 plan,546 },547 };548 }549 550 /**551 * Gate unavailable fallback — fail closed by staying in PLAN mode and552 * requiring explicit user approval before execution can proceed. The caller553 * marks the gate as user_takeover first so the next exit_plan_mode call uses554 * the normal confirmation dialog instead of re-running the automatic gate.555 */556 private fallbackToUserDecision(plan: string): ToolResult {557 // Save plan so it's on disk while the session remains in plan mode.558 try {559 this.config.savePlan(plan);560 } catch (error) {561 debugLogger.warn(562 `[ExitPlanModeTool] Failed to save plan to disk: ${error instanceof Error ? error.message : String(error)}`,563 );564 }565 566 return {567 llmContent:568 'Gate is unavailable and cannot review the plan. Ask the user whether to execute this plan or stay in plan mode to revise it.',569 returnDisplay: {570 type: 'plan_summary',571 message:572 'Plan gate is unavailable. The plan has been saved, and plan mode remains active until the user explicitly approves execution.',573 plan,574 },575 };576 }577}578 579export class ExitPlanModeTool extends BaseDeclarativeTool<580 ExitPlanModeParams,581 ToolResult582> {583 static readonly Name: string = ToolNames.EXIT_PLAN_MODE;584 585 constructor(private readonly config: Config) {586 super(587 ExitPlanModeTool.Name,588 ToolDisplayNames.EXIT_PLAN_MODE,589 exitPlanModeToolDescription,590 Kind.Think,591 exitPlanModeToolSchemaData.parametersJsonSchema as Record<592 string,593 unknown594 >,595 true, // isOutputMarkdown596 false, // canUpdateOutput597 true, // shouldDefer598 // alwaysLoad: plan mode tells the model to call exit_plan_mode directly,599 // so its schema must always be declared, not deferred (issue #5210).600 true, // alwaysLoad601 );602 }603 604 override validateToolParams(params: ExitPlanModeParams): string | null {605 if (606 !params.plan ||607 typeof params.plan !== 'string' ||608 params.plan.trim() === ''609 ) {610 return 'Parameter "plan" must be a non-empty string.';611 }612 613 return null;614 }615 616 protected createInvocation(params: ExitPlanModeParams) {617 return new ExitPlanModeToolInvocation(this.config, params);618 }619}620 