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
JavaScript
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)));
}