UNPKG

scai

Version:

> **A local-first AI CLI for understanding, querying, and iterating on large codebases.** > **100% local • No token costs • No cloud • No prompt injection • Private by design**

342 lines (335 loc) 16 kB
import { logInputOutput } from "../utils/promptLogHelper.js"; /** * REASON NEXT TASK STEP * * Determines whether the agent should: * - continue transforming remaining files * - redo a previous step * - complete execution * * Creates a TaskStep if none exists and sets context.task.currentStep. */ export const reasonNextTaskStep = { name: "reasonNextTaskStep", description: "Reasons over working files and execution artifacts to decide the next task step.", async run(context) { var _a, _b; if (!context.task) { throw new Error("reasonNextTaskStep: missing context.task"); } context.analysis || (context.analysis = {}); context.execution || (context.execution = {}); (_a = context.task).taskSteps || (_a.taskSteps = []); (_b = context.analysis).iterationReasoning || (_b.iterationReasoning = { summary: "", nextAction: "continue", rationale: "", confidence: 0.5, }); const iteration = context.analysis.iterationReasoning; // --------------------------- // 1️⃣ Determine intended files (deduplicated) // --------------------------- const plannedExecutionFiles = context.task.taskSteps .filter(step => !!step.filePath && !step.filePath.startsWith("__research__/")) .map(step => step.filePath) .filter((filePath, index, all) => all.indexOf(filePath) === index); const fallbackIntendedFiles = Array.from(new Set([ ...(context.analysis.focus?.selectedFiles ?? []), ...(context.workingFiles?.map(f => f.path).filter(Boolean) ?? []), ])); // Use union (not fallback switch) so docs-only or planning-skipped lanes // still process all selected files instead of only ad-hoc seeded taskSteps. const intendedFiles = Array.from(new Set([ ...plannedExecutionFiles, ...fallbackIntendedFiles, ])); // --------------------------- // 2️⃣ Transformed and analyzed files // --------------------------- const transformedFiles = context.execution.codeTransformArtifacts?.files.map(f => f.filePath) ?? []; const analyzedFiles = Object.entries(context.analysis.fileAnalysis ?? {}) .filter(([_, fa]) => fa.semanticAnalyzed) .map(([path]) => path); const fileAnalysis = context.analysis.fileAnalysis ?? {}; const taskSteps = context.task.taskSteps; // --------------------------- // 2.5️⃣ Prioritize pending research steps // --------------------------- const pendingResearchStep = taskSteps.find(step => typeof step.action === "string" && step.action.startsWith("research-") && step.status !== "completed"); if (pendingResearchStep) { context.task.currentStep = pendingResearchStep; context.analysis.iterationReasoning = { ...iteration, nextAction: "continue", rationale: `Research step pending: ${pendingResearchStep.action}`, confidence: 0.95, nextTargets: { files: [pendingResearchStep.filePath] }, summary: "Task step decision: continue (research priority)", }; logInputOutput("reasonNextTaskStep", "output", { pendingResearch: pendingResearchStep, iterationReasoning: context.analysis.iterationReasoning, }); return; } // --------------------------- // 2.6️⃣ Architecture-summary completion gate // --------------------------- const intentCategory = (context.analysis.intent?.intentCategory ?? "").toLowerCase(); const isSummaryLikeIntent = intentCategory === "question" || intentCategory === "explanation"; const synthesisCompleted = taskSteps.some(step => step.action === "research-architecture-synthesis" && step.status === "completed"); const tierCoverage = computeTierCoverage(context, intendedFiles, analyzedFiles, transformedFiles, taskSteps); if (isSummaryLikeIntent && synthesisCompleted && tierCoverage.satisfied) { context.task.status = "completed"; context.task.currentStep = undefined; context.analysis.iterationReasoning = { ...iteration, nextAction: "complete", rationale: `Architecture summary ready after synthesis and tiered coverage (tier1 ${tierCoverage.tier1.covered}/${tierCoverage.tier1.target}, tier2 ${tierCoverage.tier2.covered}/${tierCoverage.tier2.target}, overall ${tierCoverage.overall.covered}/${tierCoverage.overall.target}).`, confidence: 0.98, redoFiles: [], nextTargets: { files: [] }, summary: "Task step decision: complete (synthesis + tiered coverage)", }; logInputOutput("reasonNextTaskStep", "output", { intentCategory, synthesisCompleted, tiers: tierCoverage, iterationReasoning: context.analysis.iterationReasoning, }); return; } // --------------------------- // 3️⃣ Mark analysis-only and transformed files as completed // --------------------------- for (const path of analyzedFiles) { const fa = fileAnalysis[path]; const step = taskSteps.find(s => s.filePath === path); if (!step) continue; if (fa?.semanticAnalyzed && context.executionControl?.constraints?.allowFileWrites === false) { step.status = "completed"; } if (transformedFiles.includes(path)) { step.status = "completed"; } } // --------------------------- // 4️⃣ Validation-based redo // --------------------------- const validations = context.analysis.executionOutcome?.validations ?? []; const redoFiles = validations.filter(v => v.requiresRedo && v.filePath).map(v => v.filePath); iteration.redoFiles = redoFiles; // --------------------------- // 5️⃣ Task-level reasoning (cross-file) // --------------------------- // Instead of deriving remainingFiles from artifacts, use TaskStep.status const pendingFiles = intendedFiles.filter(path => { const step = taskSteps.find(s => s.filePath === path); return !step || step.status !== "completed"; }); // --------------------------- // 🛑 Deterministic Completion Gate // --------------------------- const isDeterministicallyComplete = redoFiles.length === 0 && pendingFiles.length === 0; if (isDeterministicallyComplete) { // ✅ mark task as completed context.task.status = "completed"; context.task.currentStep = undefined; context.analysis.iterationReasoning = { ...iteration, nextAction: "complete", rationale: "All selected files have completed TaskSteps and passed validation.", confidence: 1.0, redoFiles: [], nextTargets: { files: [] }, summary: "Task step decision: complete", }; logInputOutput("reasonNextTaskStep", "output", { intendedFiles, pendingFiles, redoFiles, currentStep: context.task.currentStep, iterationReasoning: context.analysis.iterationReasoning, }); return; // 🔴 HARD EXIT — skip Step 6 and risk reasoning entirely } // --------------------------- // Task-level reasoning (cross-file) // --------------------------- // Remaining work should respect TaskStep completion state first. // This avoids re-selecting files that were completed via analyze-only fallback. const remainingFiles = pendingFiles.filter(path => !redoFiles.includes(path)); // --------------------------- // Decide next step // --------------------------- let nextAction = "continue"; let rationale = ""; let confidence = 0.9; let nextFile; if (redoFiles.length > 0) { nextAction = "redo-step"; rationale = `Some transformed files failed validation and require redo: ${redoFiles.join(", ")}`; nextFile = redoFiles[0]; confidence = 0.7; } else if (remainingFiles.length > 0) { nextAction = "continue"; rationale = `Not all selected files have been analyzed or transformed yet. Remaining files: ${remainingFiles.join(", ")}`; nextFile = remainingFiles[0]; confidence = 0.95; } else { nextAction = "complete"; rationale = "All selected files have been analyzed, transformed, and validated successfully."; confidence = 0.98; } /* // --------------------------- // 6.5️⃣ Optional: Reason over known risks // --------------------------- const knownRisks = context.analysis.understanding?.risks ?? []; if (knownRisks.length > 0) { // Optionally call the LLM with constrained instructions const riskPrompt = ` You are given the following KNOWN RISKS (authoritative, do not invent new ones): ${knownRisks.map(r => "- " + r).join("\n")} Task: - Decide whether it is reasonable to ask the user for clarification before proceeding. - Return STRICT JSON: { askUser: true|false, rationale: string } `; try { const aiResponse = await generate({ query: context.initContext?.userQuery ?? "", content: riskPrompt }); const cleaned = await cleanupModule.run({ query: context.initContext?.userQuery ?? "", content: aiResponse.data ?? "" }); const parsed = cleaned.data; // type guard if ( parsed && typeof parsed === "object" && "askUser" in parsed && "rationale" in parsed && typeof (parsed as { rationale?: unknown }).rationale === "string" ) { if ((parsed as { askUser: boolean }).askUser) { nextAction = "request-feedback"; rationale += `\nUser clarification recommended due to known risks: ${(parsed as { rationale: string }).rationale}`; confidence = Math.min(confidence, 0.8); // slightly lower because human needed } } } catch (err) { console.warn("[reasonNextTaskStep] Risk reasoning failed", err); // fallback: ignore, keep deterministic nextAction } } */ // --------------------------- // 7️⃣ Ensure a TaskStep exists for nextFile // --------------------------- if (nextFile) { let nextStep = taskSteps.find(s => s.filePath === nextFile); if (!nextStep) { nextStep = { taskId: context.task.id, filePath: nextFile, status: "pending", }; context.task.taskSteps.push(nextStep); } if (nextAction === "redo-step") { nextStep.status = "pending"; nextStep.startTime = undefined; nextStep.endTime = undefined; if (nextStep.result && typeof nextStep.result === "object") { delete nextStep.result.stepReasoning; } } context.task.currentStep = nextStep; } // --------------------------- // 8️⃣ Persist reasoning // --------------------------- context.analysis.iterationReasoning = { ...iteration, nextAction, rationale, confidence, nextTargets: { files: Array.from(new Set(redoFiles.length > 0 ? redoFiles : remainingFiles)) }, summary: `Task step decision: ${nextAction}`, }; logInputOutput("reasonNextTaskStep", "output", { intendedFiles, analyzedFiles, transformedFiles, remainingFiles, redoFiles, currentStep: context.task.currentStep, iterationReasoning: context.analysis.iterationReasoning, }); }, }; /** * Computes tiered coverage using selected/research/verify signals. * Tier1 is highest-priority, tier2 is remaining selected, tier3 is the rest. */ function computeTierCoverage(context, intendedFiles, analyzedFiles, transformedFiles, taskSteps) { const selectedSet = new Set(context.analysis?.focus?.selectedFiles ?? []); const researchTouchedSet = new Set(context.analysis?.researchArtifacts?.touchedFiles ?? []); const verifyByFile = context.analysis?.verify?.byFile ?? {}; const plannedRankByFile = buildPlannedRankMap(taskSteps); const coveredSet = new Set([...analyzedFiles, ...transformedFiles]); const tier1Files = intendedFiles.filter(filePath => { const verifyConfidence = verifyByFile[filePath]?.fileConfidence ?? 0; const plannedRank = plannedRankByFile.get(filePath); const selected = selectedSet.has(filePath); const touchedByResearch = researchTouchedSet.has(filePath); const highVerifyConfidence = verifyConfidence >= 0.65; const highPlanPriority = typeof plannedRank === "number" && plannedRank <= 1; return selected && (touchedByResearch || highVerifyConfidence || highPlanPriority); }); const tier2Files = intendedFiles.filter(filePath => !tier1Files.includes(filePath) && selectedSet.has(filePath)); const tier3Files = intendedFiles.filter(filePath => !tier1Files.includes(filePath) && !tier2Files.includes(filePath)); const tier1Covered = tier1Files.filter(filePath => coveredSet.has(filePath)).length; const tier2Covered = tier2Files.filter(filePath => coveredSet.has(filePath)).length; const tier3Covered = tier3Files.filter(filePath => coveredSet.has(filePath)).length; const overallCovered = intendedFiles.filter(filePath => coveredSet.has(filePath)).length; const tier1Target = computeCoverageTarget(tier1Files.length, 0.7); const tier2Target = computeCoverageTarget(tier2Files.length, 0.4); const overallTarget = Math.min(Math.min(6, Math.max(4, intendedFiles.length)), intendedFiles.length); const satisfied = tier1Covered >= tier1Target && tier2Covered >= tier2Target && overallCovered >= overallTarget; return { tier1: { files: tier1Files, covered: tier1Covered, target: tier1Target }, tier2: { files: tier2Files, covered: tier2Covered, target: tier2Target }, tier3: { files: tier3Files, covered: tier3Covered, target: tier3Files.length }, overall: { covered: overallCovered, target: overallTarget }, satisfied, }; } /** * Reads planning rank from task step metadata. * Lower rank means higher priority. */ function buildPlannedRankMap(taskSteps) { return new Map(taskSteps .filter(step => !!step.filePath && !step.filePath.startsWith("__research__/")) .map(step => [ step.filePath, typeof step.result?.priorityRank === "number" ? step.result.priorityRank : undefined, ])); } function computeCoverageTarget(total, ratio) { if (total === 0) return 0; return Math.min(total, Math.max(1, Math.ceil(total * ratio))); }