CoolFace
Datasetpublic

basant307/AI_Governance_Project

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
0likes45downloads
planApprovalGate.ts275 linesDownload Raw Back to plan-gate
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 
basant307/AI_Governance_Project · CoolFace