basant307/AI_Governance_Project
045
1/**2 * @license3 * Copyright 2025 Qwen4 * SPDX-License-Identifier: Apache-2.05 */6 7/**8 * Plan Approval Gate orchestrator.9 *10 * Runs a single gate review agent, assigns stable finding ids, and11 * produces a {@link GateDecision}.12 *13 * This module is called from `ExitPlanModeToolInvocation.execute()` when14 * the pre-plan mode is AUTO or YOLO.15 */16 17import type { Config } from '../config/config.js';18import type {19 GateAgentResult,20 MergedGateFinding,21 GateDecision,22 EvidenceBundle,23} from './types.js';24import {25 CAPPED_REVIEW_LIMIT,26 MAX_AGENT_RETRIES,27 CAP_ESCALATION_LABELS,28} from './types.js';29import { runGateAgent } from './gateReviewAgents.js';30import { createDebugLogger } from '../utils/debugLogger.js';31import { delay } from '../utils/retry.js';32 33const debugLogger = createDebugLogger('PLAN_APPROVAL_GATE');34 35// ── Public entry point ─────────────────────────────────────────────────36 37/**38 * Run a single round of the Plan Approval Gate. The caller39 * (ExitPlanModeTool) is responsible for the outer capped/uncapped loop40 * and for persisting the gate state between rounds.41 */42export async function runPlanApprovalGate(43 config: Config,44 bundle: EvidenceBundle,45 signal: AbortSignal,46): Promise<GateDecision> {47 const gateState = config.getPlanGateState();48 if (!gateState) {49 return { kind: 'unavailable', reason: 'No active plan gate state' };50 }51 52 // ── Run single agent with retry ──────────────────────────────────53 const result = await runAgentWithRetry(config, bundle, signal);54 55 if (result === null) {56 return {57 kind: 'unavailable',58 reason: `Gate review agent unavailable after ${MAX_AGENT_RETRIES} retries`,59 };60 }61 62 // ── Assign stable finding ids ────────────────────────────────────63 const findings = assignFindingIds(result);64 65 // Update gate state66 gateState.reviewCount++;67 gateState.lastFindings = findings;68 69 // ── Determine decision ───────────────────────────────────────────70 // Branch on result.decision first — only 'pass' may approve.71 72 // Safety: agent self-reporting unavailable should never auto-approve73 if (result.decision === 'unavailable') {74 return {75 kind: 'unavailable',76 reason: 'Gate review agent reported itself as unavailable',77 };78 }79 80 // 'pass' with zero findings → approved81 if (result.decision === 'pass') {82 if (findings.length === 0) {83 return { kind: 'approved' };84 }85 // 'pass' but agent emitted findings anyway — treat as blocked for safety86 debugLogger.warn(87 `Gate agent returned 'pass' with ${findings.length} finding(s); treating as blocked`,88 );89 }90 91 // 'needs_user' — collect questions92 if (result.decision === 'needs_user') {93 const questions = result.findings94 .filter((f) => f.suggestedQuestion)95 .map((f) => f.suggestedQuestion!);96 if (questions.length > 0) {97 return { kind: 'needs_user', findings, questions };98 }99 // needs_user without actionable questions — fall through to blocked100 debugLogger.warn(101 'Gate agent returned needs_user with no suggestedQuestion; treating as blocked',102 );103 }104 105 // 'blocked' (or fallthrough from pass-with-findings / needs_user-without-questions)106 // with zero findings — treat as unavailable (cannot produce actionable feedback)107 if (findings.length === 0) {108 return {109 kind: 'unavailable',110 reason: `Gate agent returned '${result.decision}' with no findings`,111 };112 }113 114 // Check cap115 const isCapped = gateState.gateMode === 'capped';116 const atCap = isCapped && gateState.reviewCount >= CAPPED_REVIEW_LIMIT;117 118 const hasBlocking = findings.some(119 (f) => f.severity === 'P1' || f.severity === 'P2',120 );121 122 if (atCap) {123 if (!hasBlocking) {124 return { kind: 'approved', nonBlockingFindings: findings };125 }126 return {127 kind: 'cap_escalation',128 blockingFindings: findings.filter(129 (f) => f.severity === 'P1' || f.severity === 'P2',130 ),131 };132 }133 134 // Not at cap: any finding blocks (P1/P2/P3 all block pre-cap)135 return { kind: 'blocked', findings };136}137 138// ── Agent execution with retry ─────────────────────────────────────────139 140async function runAgentWithRetry(141 config: Config,142 bundle: EvidenceBundle,143 signal: AbortSignal,144): Promise<GateAgentResult | null> {145 // Entry-time check: if the parent signal is already aborted before we start,146 // respect it to avoid launching a 5-minute gate agent for an obvious cancellation.147 // This is the only synchronous signal.aborted check before calling runGateAgent.148 // The retry loop remains abort-aware via delay() which rejects on abort,149 // providing a cancellation point between attempts without monitoring mid-flight.150 if (signal.aborted) {151 debugLogger.warn(152 'Gate agent skipped: parent signal already aborted at entry',153 );154 return null;155 }156 157 for (let attempt = 1; attempt <= MAX_AGENT_RETRIES; attempt++) {158 try {159 return await runGateAgent(config, bundle, signal);160 } catch (error) {161 const msg = error instanceof Error ? error.message : String(error);162 debugLogger.warn(163 `Gate agent attempt ${attempt}/${MAX_AGENT_RETRIES} failed: ${msg}`,164 );165 if (attempt === MAX_AGENT_RETRIES) {166 debugLogger.error(167 `Gate agent exhausted all ${MAX_AGENT_RETRIES} retries`,168 );169 return null;170 }171 // Abort-aware delay: wait 1s between retries (not after the final attempt).172 // Uses the existing `delay()` from utils/retry.ts, which rejects when the173 // signal is aborted. A cancellation during the wait breaks the loop174 // immediately rather than proceeding to another rapid-fire attempt.175 try {176 await delay(1000, signal);177 } catch {178 // Signal aborted during delay — stop retrying179 debugLogger.warn(180 `Gate agent retry loop cancelled during backoff delay (attempt ${attempt}/${MAX_AGENT_RETRIES})`,181 );182 return null;183 }184 }185 }186 return null;187}188 189// ── Finding id assignment ──────────────────────────────────────────────190 191/**192 * Assigns stable GF-N ids to findings from the single agent's result.193 */194export function assignFindingIds(result: GateAgentResult): MergedGateFinding[] {195 return result.findings.map((finding, i) => ({196 id: `GF-${i + 1}`,197 severity: finding.severity,198 issue: finding.issue,199 rationale: finding.rationale,200 suggestedFix: finding.suggestedFix,201 suggestedQuestion: finding.suggestedQuestion,202 }));203}204 205// ── Formatting helpers for exit_plan_mode responses ────────────────────206 207export function formatBlockedResponse(208 decision: GateDecision & { kind: 'blocked' },209): string {210 const lines = [211 'Plan Approval Gate: **blocked**. The following issues must be resolved before the plan can be executed:\n',212 ];213 for (const f of decision.findings) {214 lines.push(215 `- **${f.id}** [${f.severity}]: ${f.issue}\n _Rationale:_ ${f.rationale}`,216 );217 if (f.suggestedFix) {218 lines.push(` _Suggested fix:_ ${f.suggestedFix}`);219 }220 }221 lines.push(222 '\nRevise the plan to address each finding, then call exit_plan_mode again. Include a resolutionSummary referencing each finding id (e.g. GF-1).',223 );224 return lines.join('\n');225}226 227export function formatNeedsUserResponse(228 decision: GateDecision & { kind: 'needs_user' },229): string {230 const lines = [231 'Plan Approval Gate: **needs_user**. The gate requires user input before it can approve.\n',232 ];233 for (const f of decision.findings) {234 lines.push(`- **${f.id}** [${f.severity}]: ${f.issue}`);235 }236 lines.push('\nSuggested questions to ask the user:');237 for (const q of decision.questions) {238 lines.push(`- ${q}`);239 }240 lines.push(241 '\nUse AskUserQuestion with metadata `{ source: "plan_gate_needs_user" }` to ask the user, then revise the plan and call exit_plan_mode again.',242 );243 return lines.join('\n');244}245 246export function formatCapEscalationResponse(247 decision: GateDecision & { kind: 'cap_escalation' },248): string {249 const lines = [250 `Plan Approval Gate: **cap reached** with ${decision.blockingFindings.length} blocking finding(s) remaining.\n`,251 'You must present these to the user via AskUserQuestion with metadata `{ source: "plan_gate_cap" }`.\n',252 'The question body must list the remaining blocking findings:\n',253 ];254 for (const f of decision.blockingFindings) {255 lines.push(256 `- **${f.id}** [${f.severity}]: ${f.issue}\n _Rationale:_ ${f.rationale}`,257 );258 }259 lines.push(260 '\nProvide these options (the UI automatically provides a free-text "Other" input):',261 `1. "${CAP_ESCALATION_LABELS.CONTINUE}" — keep iterating with the gate (uncapped)`,262 `2. "${CAP_ESCALATION_LABELS.APPROVE}" — user override, skip the gate and execute`,263 );264 return lines.join('\n');265}266 267export function formatApprovedNotes(findings: MergedGateFinding[]): string {268 if (findings.length === 0) return '';269 const lines = ['Non-blocking review notes (P3, not required to address):\n'];270 for (const f of findings) {271 lines.push(`- **${f.id}** [${f.severity}]: ${f.issue}`);272 }273 return lines.join('\n');274}275 