CoolFace
Datasetpublic

basant307/AI_Governance_Project

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
0likes48downloads
exitPlanMode.ts620 linesDownload Raw Back to tools
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 
basant307/AI_Governance_Project · CoolFace