UNPKG

@autobe/agent

Version:

AI backend server code generator

1,369 lines (1,281 loc) 55.9 kB
import { AgenticaValidationError } from "@agentica/core"; import { AutoBeAnalyze, AutoBeAnalyzeHistory, AutoBeAnalyzeScenarioEvent, AutoBeAnalyzeSectionReviewEvent, AutoBeAnalyzeSectionReviewFileResult, AutoBeAnalyzeSectionReviewIssue, AutoBeAnalyzeSectionReviewRejectedModuleUnit, AutoBeAnalyzeWriteModuleEvent, AutoBeAnalyzeWriteSectionEvent, AutoBeAnalyzeWriteUnitEvent, AutoBeAssistantMessageHistory, AutoBeProgressEventBase, } from "@autobe/interface"; import { v7 } from "uuid"; import { AutoBeConfigConstant } from "../../constants/AutoBeConfigConstant"; import { AutoBeContext } from "../../context/AutoBeContext"; import { AutoBePreliminaryExhaustedError } from "../../utils/AutoBePreliminaryExhaustedError"; import { AutoBeTimeoutError } from "../../utils/AutoBeTimeoutError"; import { executeCachedBatch } from "../../utils/executeCachedBatch"; import { fillTocDeterministic } from "./fillTocDeterministic"; import { orchestrateAnalyzeExtractDecisions } from "./orchestrateAnalyzeExtractDecisions"; import { orchestrateAnalyzeScenario } from "./orchestrateAnalyzeScenario"; import { orchestrateAnalyzeSectionCrossFileReview } from "./orchestrateAnalyzeSectionCrossFileReview"; import { orchestrateAnalyzeWriteSection } from "./orchestrateAnalyzeWriteSection"; import { orchestrateAnalyzeWriteSectionPatch } from "./orchestrateAnalyzeWriteSectionPatch"; import { orchestrateAnalyzeWriteUnit } from "./orchestrateAnalyzeWriteUnit"; import { assembleContent, assembleModule, } from "./programmers/AutoBeAnalyzeProgrammer"; import { FixedAnalyzeTemplateFeature, FixedAnalyzeTemplateUnitTemplate, buildFixedAnalyzeExpandedTemplate, expandFixedAnalyzeTemplateUnits, } from "./structures/FixedAnalyzeTemplate"; import { buildFileAttributeDuplicateMap, buildFileConflictMap, buildFileEnumConflictMap, buildFilePermissionConflictMap, buildFileStateFieldConflictMap, detectAttributeDuplicates, detectConstraintConflicts, detectEnumConflicts, detectPermissionConflicts, detectStateFieldConflicts, } from "./utils/buildConstraintConsistencyReport"; import { buildFileErrorCodeConflictMap, detectErrorCodeConflicts, } from "./utils/buildErrorCodeRegistry"; import { detectOversizedToc } from "./utils/buildHardValidators"; import { IFileDecisions, buildFileDecisionConflictMap, detectDecisionConflicts, } from "./utils/detectDecisionConflicts"; import { buildFileProseConflictMap, detectProseConstraintConflicts, } from "./utils/detectProseConstraintConflicts"; import { validateScenarioBasics } from "./utils/validateScenarioBasics"; /** * Per-file state tracking across all three stages (Module → Unit → Section). * * Maintains each file's intermediate results and cross-file review feedback * throughout the stage-synchronized pipeline. */ interface IFileState { file: AutoBeAnalyze.IFileScenario; moduleResult: AutoBeAnalyzeWriteModuleEvent | null; unitResults: AutoBeAnalyzeWriteUnitEvent[] | null; sectionResults: AutoBeAnalyzeWriteSectionEvent[][] | null; sectionFeedback?: string; // Section-stage partial regeneration tracking rejectedModuleUnits?: AutoBeAnalyzeSectionReviewRejectedModuleUnit[] | null; sectionRetryCount?: number; sectionReviewCount?: number; sectionStagnationCount?: number; lastSectionContentSignature?: string; lastSectionRejectionSignature?: string; } const ANALYZE_SCENARIO_MAX_RETRY = 2; const ANALYZE_SECTION_FILE_MAX_RETRY = 5; const ANALYZE_SECTION_FILE_MAX_REVIEW = 2; const ANALYZE_SECTION_STAGNATION_MAX = 4; const ANALYZE_DEBUG_LOG = process.env.AUTOBE_DEBUG_ANALYZE === "1"; const analyzeDebug = (message: string): void => { if (!ANALYZE_DEBUG_LOG) return; console.log(`[analyze-debug] ${new Date().toISOString()} ${message}`); }; export const orchestrateAnalyze = async ( ctx: AutoBeContext, ): Promise<AutoBeAssistantMessageHistory | AutoBeAnalyzeHistory> => { // Initialize analysis state const step: number = (ctx.state().analyze?.step ?? -1) + 1; const startTime: Date = new Date(); ctx.dispatch({ type: "analyzeStart", id: v7(), step, created_at: startTime.toISOString(), }); // Generate analysis scenario with pre-check + LLM review + retry loop let scenario!: AutoBeAnalyzeScenarioEvent; let scenarioFeedback: string | undefined; for (let attempt = 0; attempt <= ANALYZE_SCENARIO_MAX_RETRY; attempt++) { const rawScenario = await orchestrateAnalyzeScenario(ctx, { feedback: scenarioFeedback, }); if (rawScenario.type === "assistantMessage") return ctx.assistantMessage(rawScenario); // 1) Programmatic pre-check const preCheck = validateScenarioBasics({ prefix: rawScenario.prefix, actors: rawScenario.actors, entities: rawScenario.entities, }); if (!preCheck.valid && attempt < ANALYZE_SCENARIO_MAX_RETRY) { analyzeDebug( `Scenario pre-check failed (attempt ${attempt}): ${preCheck.errors.join("; ")}`, ); scenarioFeedback = `Programmatic validation failed:\n${preCheck.errors.join("\n")}`; continue; } // Accept scenario directly (write agent self-reviews during rewrite loop) analyzeDebug(`Scenario accepted (attempt ${attempt})`); scenario = rawScenario; ctx.dispatch(scenario); break; } // Initialize per-file state const fileStates: IFileState[] = scenario.files.map((file) => ({ file, moduleResult: null, unitResults: null, sectionResults: null, })); // Progress tracking for each stage const moduleWriteProgress: AutoBeProgressEventBase = { total: scenario.files.length, completed: 0, }; const unitWriteProgress: AutoBeProgressEventBase = { total: 0, completed: 0, }; const sectionWriteProgress: AutoBeProgressEventBase = { total: 0, completed: 0, }; const perFileSectionReviewProgress: AutoBeProgressEventBase = { total: 0, completed: 0, }; const crossFileSectionReviewProgress: AutoBeProgressEventBase = { total: 1, completed: 0, }; // === STAGE 1: MODULE (deterministic — no LLM) === processStageModuleDeterministic(ctx, { scenario, fileStates, moduleWriteProgress, }); // === STAGE 2: UNIT (fixed units deterministic, dynamic units LLM) === await processStageUnit(ctx, { scenario, fileStates, unitWriteProgress, }); // === STAGE 3: SECTION (01-05 only, TOC excluded) === await processStageSection(ctx, { scenario, fileStates, sectionWriteProgress, perFileSectionReviewProgress, crossFileSectionReviewProgress, }); // === TOC FILL (deterministic — no LLM) === const expandedTemplate = buildFixedAnalyzeExpandedTemplate( (scenario.features ?? []) as FixedAnalyzeTemplateFeature[], ); const tocIndex = fileStates.findIndex((s) => s.file.filename === "00-toc.md"); let tocContent: string | null = null; if (tocIndex >= 0) { tocContent = fillTocDeterministic(ctx, { scenario, tocFileState: fileStates[tocIndex]!, otherFileStates: fileStates.filter((_, i) => i !== tocIndex), expandedTemplate, }); } // === ASSEMBLE === const files: AutoBeAnalyze.IFile[] = []; for (let fileIndex = 0; fileIndex < fileStates.length; fileIndex++) { const state = fileStates[fileIndex]!; // TOC uses flat content directly (no module/unit hierarchy) const content = fileIndex === tocIndex ? tocContent! : assembleContent( state.moduleResult!, state.unitResults!, state.sectionResults!, ); const module = assembleModule( state.moduleResult!, state.unitResults!, state.sectionResults!, ); files.push({ ...state.file, title: state.moduleResult!.title, summary: state.moduleResult!.summary, content, module, }); } // Complete the analysis return ctx.dispatch({ type: "analyzeComplete", id: v7(), actors: scenario.actors, prefix: scenario.prefix, files, aggregates: ctx.getCurrentAggregates("analyze"), step, elapsed: new Date().getTime() - startTime.getTime(), created_at: new Date().toISOString(), }) satisfies AutoBeAnalyzeHistory; }; // MODULE (deterministic — no LLM calls) /** * Generate module structure deterministically from FixedAnalyzeTemplate. * * No LLM calls needed — module titles, purposes, and structure are all derived * from the fixed 6-file SRS template. */ function processStageModuleDeterministic( ctx: AutoBeContext, props: { scenario: AutoBeAnalyzeScenarioEvent; fileStates: IFileState[]; moduleWriteProgress: AutoBeProgressEventBase; }, ): void { const expandedTemplate = buildFixedAnalyzeExpandedTemplate( (props.scenario.features ?? []) as FixedAnalyzeTemplateFeature[], ); for (const [i, state] of props.fileStates.entries()) { const template = expandedTemplate[i]!; const moduleEvent: AutoBeAnalyzeWriteModuleEvent = { type: "analyzeWriteModule", id: v7(), title: `${props.scenario.prefix} — ${template.description}`, summary: template.description, moduleSections: template.modules.map((m) => ({ title: m.title, purpose: m.purpose, content: m.purpose, })), step: (ctx.state().analyze?.step ?? -1) + 1, retry: 0, total: props.fileStates.length, completed: i + 1, tokenUsage: { total: 0, input: { total: 0, cached: 0 }, output: { total: 0, reasoning: 0, accepted_prediction: 0, rejected_prediction: 0, }, }, metric: { attempt: 0, success: 0, consent: 0, validationFailure: 0, invalidJson: 0, }, acquisition: { previousAnalysisSections: [] }, created_at: new Date().toISOString(), }; state.moduleResult = moduleEvent; ctx.dispatch(moduleEvent); props.moduleWriteProgress.completed++; } } // UNIT /** * Process the Unit stage for all files. * * Fixed-strategy modules get deterministic unit generation (no LLM). * Dynamic-strategy modules (perEntity/perActor/perEntityGroup) use LLM. No * cross-file unit review — Hard Validators at section stage handle * consistency. */ async function processStageUnit( ctx: AutoBeContext, props: { scenario: AutoBeAnalyzeScenarioEvent; fileStates: IFileState[]; unitWriteProgress: AutoBeProgressEventBase; }, ): Promise<void> { const promptCacheKey: string = v7(); const expandedTemplate = buildFixedAnalyzeExpandedTemplate( (props.scenario.features ?? []) as FixedAnalyzeTemplateFeature[], ); // Count total units needed for progress tracking for (const [fileIndex, state] of props.fileStates.entries()) { const template = expandedTemplate[fileIndex]!; props.unitWriteProgress.total += template.modules.length; void state; // used below } await executeCachedBatch( ctx, props.fileStates.map((state, fileIndex) => async (cacheKey) => { // TOC is filled deterministically after all other files complete if (state.file.filename === "00-toc.md") return []; const moduleResult: AutoBeAnalyzeWriteModuleEvent = state.moduleResult!; const template = expandedTemplate[fileIndex]!; analyzeDebug( `unit file-start fileIndex=${fileIndex} file="${state.file.filename}"`, ); const unitResults: AutoBeAnalyzeWriteUnitEvent[] = []; for ( let moduleIndex: number = 0; moduleIndex < moduleResult.moduleSections.length; moduleIndex++ ) { const moduleTemplate = template.modules[moduleIndex]!; const strategy = moduleTemplate.unitStrategy; if (strategy.type === "fixed") { // Deterministic unit generation — no LLM const unitEvent = buildDeterministicUnitEvent(ctx, { moduleIndex, units: strategy.units, progress: props.unitWriteProgress, }); ctx.dispatch(unitEvent); unitResults.push(unitEvent); } else { // Dynamic units — expand from template, then LLM writes content+keywords const unitStart: number = Date.now(); analyzeDebug( `unit module-start fileIndex=${fileIndex} file="${state.file.filename}" moduleIndex=${moduleIndex} strategy=${strategy.type}`, ); try { const unitEvent: AutoBeAnalyzeWriteUnitEvent = await orchestrateAnalyzeWriteUnit(ctx, { scenario: props.scenario, file: state.file, moduleEvent: moduleResult, moduleIndex, progress: props.unitWriteProgress, promptCacheKey: cacheKey, retry: 0, }); analyzeDebug( `unit module-done fileIndex=${fileIndex} file="${state.file.filename}" moduleIndex=${moduleIndex} unitCount=${unitEvent.unitSections.length} elapsedMs=${Date.now() - unitStart}`, ); unitResults.push(unitEvent); } catch (e) { if ( e instanceof AgenticaValidationError || e instanceof AutoBePreliminaryExhaustedError || e instanceof AutoBeTimeoutError ) { analyzeDebug( `unit module-skipped fileIndex=${fileIndex} file="${state.file.filename}" moduleIndex=${moduleIndex} error=${(e as Error).constructor.name} elapsedMs=${Date.now() - unitStart} — using fallback`, ); const expandedUnits = expandFixedAnalyzeTemplateUnits( moduleTemplate, props.scenario.entities, props.scenario.actors, ); const fallbackEvent = buildDeterministicUnitEvent(ctx, { moduleIndex, units: expandedUnits, progress: props.unitWriteProgress, }); ctx.dispatch(fallbackEvent); unitResults.push(fallbackEvent); } else { throw e; } } } } state.unitResults = unitResults; analyzeDebug( `unit file-done fileIndex=${fileIndex} file="${state.file.filename}"`, ); return unitResults; }), promptCacheKey, ); } /** Build a deterministic AutoBeAnalyzeWriteUnitEvent for fixed-strategy modules. */ function buildDeterministicUnitEvent( ctx: AutoBeContext, props: { moduleIndex: number; units: FixedAnalyzeTemplateUnitTemplate[]; progress: AutoBeProgressEventBase; }, ): AutoBeAnalyzeWriteUnitEvent { props.progress.completed++; return { type: "analyzeWriteUnit", id: v7(), moduleIndex: props.moduleIndex, unitSections: props.units.map((u) => ({ title: u.titlePattern, purpose: u.purposePattern, content: u.purposePattern, keywords: [...u.keywords], })), step: (ctx.state().analyze?.step ?? -1) + 1, retry: 0, total: props.progress.total, completed: props.progress.completed, tokenUsage: { total: 0, input: { total: 0, cached: 0 }, output: { total: 0, reasoning: 0, accepted_prediction: 0, rejected_prediction: 0, }, }, metric: { attempt: 0, success: 0, consent: 0, validationFailure: 0, invalidJson: 0, }, acquisition: { previousAnalysisSections: [] }, created_at: new Date().toISOString(), }; } // SECTION /** * Process the Section stage for all files with 2-pass review. * * Flow: * * 1. Write sections for pending files in parallel * 2. Pass 1: Per-file detailed review (parallel) — validates EARS format, value * consistency, bridge blocks, intra-file deduplication * 3. Pass 2: Cross-file lightweight review (single call) — validates terminology * alignment, value consistency across files, naming conventions * 4. Merge results from both passes — reject if either pass rejects * 5. Retry only rejected files (max 3 attempts) */ async function processStageSection( ctx: AutoBeContext, props: { scenario: AutoBeAnalyzeScenarioEvent; fileStates: IFileState[]; sectionWriteProgress: AutoBeProgressEventBase; perFileSectionReviewProgress: AutoBeProgressEventBase; crossFileSectionReviewProgress: AutoBeProgressEventBase; }, ): Promise<void> { // Exclude TOC (00-toc.md) — it is filled deterministically after all files const pendingIndices: Set<number> = new Set( props.fileStates .map((s, i) => (s.file.filename === "00-toc.md" ? -1 : i)) .filter((i) => i >= 0), ); let crossFileReviewCount: number = 0; for ( let attempt: number = 0; attempt < AutoBeConfigConstant.ANALYZE_RETRY && pendingIndices.size > 0; attempt++ ) { // Dynamically increase progress for retries (module-level granularity) const pendingModuleCount = [...pendingIndices].reduce( (sum, fi) => sum + (props.fileStates[fi]?.unitResults?.length ?? 1), 0, ); props.perFileSectionReviewProgress.total += pendingModuleCount; if (attempt > 0) { props.crossFileSectionReviewProgress.total++; } // Write sections for pending files in parallel const pendingArray: number[] = [...pendingIndices]; const sectionFileBatches: number[][] = chunkSectionFileIndices( pendingArray, computeSectionBatchSize({ attempt, pendingCount: pendingArray.length, }), ); const promptCacheKey: string = v7(); // Build scenario entity name list for invention validation (P0-B) const scenarioEntityNames = props.scenario.entities.map((e) => e.name); for (const sectionBatch of sectionFileBatches) await executeCachedBatch( ctx, sectionBatch.map((fileIndex) => async (cacheKey) => { const state: IFileState = props.fileStates[fileIndex]!; const moduleResult: AutoBeAnalyzeWriteModuleEvent = state.moduleResult!; const unitResults: AutoBeAnalyzeWriteUnitEvent[] = state.unitResults!; analyzeDebug( `section file-start attempt=${attempt} fileIndex=${fileIndex} file="${state.file.filename}" batchSize=${sectionBatch.length}`, ); // Build rejected module/unit lookup for selective regeneration const rejectedSet: Set<string> | null = buildRejectedSet( state.rejectedModuleUnits, ); const feedbackMap: Map<string, ISectionAwareFeedback> = buildFeedbackMap(state.rejectedModuleUnits); // Increase write progress only for sections that will be regenerated for (let mi: number = 0; mi < unitResults.length; mi++) { const unitEvent: AutoBeAnalyzeWriteUnitEvent = unitResults[mi]!; for (let ui: number = 0; ui < unitEvent.unitSections.length; ui++) { if (isSectionRejected(rejectedSet, mi, ui)) { props.sectionWriteProgress.total++; } } } // Write sections, skipping approved ones on retry const sectionResults: AutoBeAnalyzeWriteSectionEvent[][] = []; for ( let moduleIndex: number = 0; moduleIndex < unitResults.length; moduleIndex++ ) { const unitEvent: AutoBeAnalyzeWriteUnitEvent = unitResults[moduleIndex]!; const sectionsForModule: AutoBeAnalyzeWriteSectionEvent[] = []; for ( let unitIndex: number = 0; unitIndex < unitEvent.unitSections.length; unitIndex++ ) { if (isSectionRejected(rejectedSet, moduleIndex, unitIndex)) { const sectionStart: number = Date.now(); // Regenerate this section with targeted feedback const targetedInfo: ISectionAwareFeedback | undefined = feedbackMap.get(`${moduleIndex}:${unitIndex}`); const targetedFeedback: string | undefined = targetedInfo?.feedback ?? state.sectionFeedback; const targetedSectionIndices: number[] | null = targetedInfo?.sectionIndices ?? null; analyzeDebug( `section unit-start attempt=${attempt} fileIndex=${fileIndex} file="${state.file.filename}" moduleIndex=${moduleIndex} unitIndex=${unitIndex} targetSections=${targetedSectionIndices ? `[${targetedSectionIndices.join(",")}]` : "all"}`, ); const previousSection: | AutoBeAnalyzeWriteSectionEvent | undefined = state.sectionResults?.[moduleIndex]?.[unitIndex]; let sectionEvent: AutoBeAnalyzeWriteSectionEvent; try { sectionEvent = previousSection && targetedFeedback?.trim() ? await orchestrateAnalyzeWriteSectionPatch(ctx, { scenario: props.scenario, file: state.file, moduleEvent: moduleResult, unitEvent, moduleIndex, unitIndex, previousSectionEvent: previousSection, feedback: targetedFeedback, progress: props.sectionWriteProgress, promptCacheKey: cacheKey, retry: attempt, scenarioEntityNames, sectionIndices: targetedSectionIndices, }) : await orchestrateAnalyzeWriteSection(ctx, { scenario: props.scenario, file: state.file, moduleEvent: moduleResult, unitEvent, allUnitEvents: unitResults, moduleIndex, unitIndex, progress: props.sectionWriteProgress, promptCacheKey: cacheKey, feedback: targetedFeedback, retry: attempt, scenarioEntityNames, }); } catch (e) { if ( e instanceof AgenticaValidationError || e instanceof AutoBePreliminaryExhaustedError || e instanceof AutoBeTimeoutError ) { analyzeDebug( `section unit-force-pass attempt=${attempt} fileIndex=${fileIndex} file="${state.file.filename}" moduleIndex=${moduleIndex} unitIndex=${unitIndex} error=${(e as Error).constructor.name} — ${previousSection ? "reusing previous" : "using placeholder"}`, ); if (previousSection) { sectionEvent = previousSection; } else { sectionEvent = { type: "analyzeWriteSection", id: v7(), moduleIndex, unitIndex, sectionSections: [], acquisition: { previousAnalysisSections: [] }, tokenUsage: { total: 0, input: { total: 0, cached: 0 }, output: { total: 0, reasoning: 0, accepted_prediction: 0, rejected_prediction: 0, }, }, metric: { attempt: 0, success: 0, consent: 0, validationFailure: 0, invalidJson: 0, }, step: (ctx.state().analyze?.step ?? -1) + 1, total: props.sectionWriteProgress.total, completed: ++props.sectionWriteProgress.completed, retry: attempt, created_at: new Date().toISOString(), }; ctx.dispatch(sectionEvent); } } else { throw e; } } analyzeDebug( `section unit-done attempt=${attempt} fileIndex=${fileIndex} file="${state.file.filename}" moduleIndex=${moduleIndex} unitIndex=${unitIndex} sectionCount=${sectionEvent.sectionSections.length} elapsedMs=${Date.now() - sectionStart}`, ); sectionsForModule.push(sectionEvent); } else { // Keep existing approved section sectionsForModule.push( state.sectionResults![moduleIndex]![unitIndex]!, ); } } sectionResults.push(sectionsForModule); } state.sectionResults = sectionResults; analyzeDebug( `section file-write-done attempt=${attempt} fileIndex=${fileIndex} file="${state.file.filename}"`, ); // Per-module review removed — write agents self-review during rewrite loop analyzeDebug( `section file-sections-accepted attempt=${attempt} fileIndex=${fileIndex} file="${state.file.filename}"`, ); return sectionResults; }), promptCacheKey, ); // Pass 2: Cross-file lightweight review (single call) crossFileReviewCount++; if (crossFileReviewCount > ANALYZE_SECTION_FILE_MAX_REVIEW) { analyzeDebug( `[orchestrateAnalyze] Section stage: skipping cross-file review (max review ${ANALYZE_SECTION_FILE_MAX_REVIEW} exceeded)`, ); // Force-pass all pending files for (const fileIndex of pendingArray) pendingIndices.delete(fileIndex); break; } analyzeDebug(`section cross-file-validation-start attempt=${attempt}`); const filesWithSections = props.fileStates .filter((state) => state.sectionResults !== null) .map((state) => ({ file: state.file, sectionEvents: state.sectionResults!, })); // Pass 2a-pre: LLM-based key decision extraction (parallel per file) analyzeDebug(`section decision-extraction-start attempt=${attempt}`); const fileDecisions: IFileDecisions[] = await Promise.all( filesWithSections .filter(({ file }) => file.filename !== "00-toc.md") .map(({ file, sectionEvents }) => orchestrateAnalyzeExtractDecisions(ctx, { file, sectionEvents, }).catch((e) => { analyzeDebug( `section decision-extraction-error file="${file.filename}" error=${(e as Error).message}`, ); return { filename: file.filename, decisions: [] } as IFileDecisions; }), ), ); analyzeDebug( `section decision-extraction-done attempt=${attempt} files=${fileDecisions.length} totalDecisions=${fileDecisions.reduce((sum, fd) => sum + fd.decisions.length, 0)}`, ); // Pass 2a-pre2: Programmatic decision conflict detection const decisionConflicts = detectDecisionConflicts({ fileDecisions, }); const fileDecisionConflictMap: Map<string, string[]> = buildFileDecisionConflictMap(decisionConflicts); if (decisionConflicts.length > 0) { analyzeDebug( `section decision-conflicts-found count=${decisionConflicts.length}: ${decisionConflicts.map((c) => `${c.topic}.${c.decision}`).join(", ")}`, ); } // Pass 2a: Programmatic cross-file validation (BEFORE LLM review) const criticalConflicts = detectConstraintConflicts({ files: filesWithSections, }); const fileConflictMap: Map<string, string[]> = buildFileConflictMap(criticalConflicts); const attributeDuplicates = detectAttributeDuplicates({ files: filesWithSections, }); const fileAttributeDuplicateMap: Map<string, string[]> = buildFileAttributeDuplicateMap(attributeDuplicates); const enumConflicts = detectEnumConflicts({ files: filesWithSections, }); const fileEnumConflictMap: Map<string, string[]> = buildFileEnumConflictMap(enumConflicts); const permissionConflicts = detectPermissionConflicts({ files: filesWithSections, }); const filePermissionConflictMap: Map<string, string[]> = buildFilePermissionConflictMap(permissionConflicts); const stateFieldConflicts = detectStateFieldConflicts({ files: filesWithSections, }); const fileStateFieldConflictMap: Map<string, string[]> = buildFileStateFieldConflictMap(stateFieldConflicts); const errorCodeConflicts = detectErrorCodeConflicts({ files: filesWithSections, }); const fileErrorCodeConflictMap: Map<string, string[]> = buildFileErrorCodeConflictMap(errorCodeConflicts); const proseConflicts = detectProseConstraintConflicts({ files: filesWithSections, }); const fileProseConflictMap: Map<string, string[]> = buildFileProseConflictMap(proseConflicts); const oversizedTocMap: Map<number, string[]> = new Map(); for (const fileIndex of pendingArray) { const state = props.fileStates[fileIndex]!; if (state.file.filename === "00-toc.md" && state.sectionResults) { const violations = detectOversizedToc(state.sectionResults); if (violations.length > 0) { oversizedTocMap.set(fileIndex, violations); } } } // Build mechanical violation summary for LLM context const allMechanicalViolations: string[] = [ ...criticalConflicts.map( (c) => `Constraint conflict: ${c.key} — ${c.values.map((v) => `"${v.display}" in [${v.files.join(", ")}]`).join(" vs ")}`, ), ...attributeDuplicates.map( (d) => `Attribute duplication: ${d.key} in [${d.files.join(", ")}]`, ), ...enumConflicts.map( (c) => `Enum conflict: ${c.key} — ${c.values.map((v) => `enum(${v.enumSet}) in [${v.files.join(", ")}]`).join(" vs ")}`, ), ...errorCodeConflicts.map( (c) => `Error code conflict: ${c.conditionKey} — ${c.codes.map((cd) => `HTTP ${cd.httpStatus} in [${cd.files.join(", ")}]`).join(" vs ")}`, ), ...proseConflicts.map( (c) => `Prose constraint conflict: ${c.entityAttr} — canonical [${c.canonicalValues.join(", ")}] vs prose [${c.proseValues.join(", ")}] in ${c.file}`, ), ...decisionConflicts.map( (c) => `Decision conflict: ${c.topic}.${c.decision} — ${c.values.map((v) => `"${v.value}" in [${v.files.join(", ")}]`).join(" vs ")}`, ), ]; const mechanicalViolationSummary = allMechanicalViolations.length > 0 ? allMechanicalViolations.join("\n") : undefined; // Pass 2b: Cross-file semantic LLM review (with mechanical violations excluded) analyzeDebug(`section cross-file-review-start attempt=${attempt}`); let crossFileReviewEvent: AutoBeAnalyzeSectionReviewEvent | null = null; try { crossFileReviewEvent = await orchestrateAnalyzeSectionCrossFileReview( ctx, { scenario: props.scenario, allFileSummaries: props.fileStates .filter((s) => s.file.filename !== "00-toc.md") .map((state) => { const fi = props.fileStates.indexOf(state); return { file: state.file, moduleEvent: state.moduleResult!, unitEvents: state.unitResults!, sectionEvents: state.sectionResults!, status: pendingIndices.has(fi) ? attempt === 0 ? ("new" as const) : ("rewritten" as const) : ("approved" as const), }; }), mechanicalViolationSummary, fileDecisions, progress: props.crossFileSectionReviewProgress, promptCacheKey, retry: attempt, }, ); } catch (e) { if ( e instanceof AgenticaValidationError || e instanceof AutoBeTimeoutError || e instanceof AutoBePreliminaryExhaustedError ) { analyzeDebug( `section cross-file-review-force-pass attempt=${attempt} error=${(e as Error).constructor.name} — force-passing all pending files`, ); for (const fileIndex of pendingArray) pendingIndices.delete(fileIndex); break; } throw e; } analyzeDebug( `section cross-file-review-done attempt=${attempt} results=${crossFileReviewEvent.fileResults.length}`, ); // Merge results from both passes const crossFileResultMap: Map< number, AutoBeAnalyzeSectionReviewFileResult > = new Map(); const validCrossFileResults = filterValidFileResults( crossFileReviewEvent.fileResults, props.fileStates.length, "Section cross-file review", ); for (const fr of validCrossFileResults) crossFileResultMap.set(fr.fileIndex, fr); for (const fileIndex of pendingArray) { const state: IFileState = props.fileStates[fileIndex]!; // Increment review count and force-pass if exceeded limit state.sectionReviewCount = (state.sectionReviewCount ?? 0) + 1; if (state.sectionReviewCount > ANALYZE_SECTION_FILE_MAX_REVIEW) { analyzeDebug( `[orchestrateAnalyze] Section stage: force-passing (max review ${ANALYZE_SECTION_FILE_MAX_REVIEW} exceeded) for file "${state.file.filename}"`, ); pendingIndices.delete(fileIndex); continue; } const crossFileResult = crossFileResultMap.get(fileIndex); const perFileApproved = true; // per-file LLM review removed const crossFileApproved = crossFileResult?.approved ?? true; // Check if this file has programmatically-detected critical conflicts const filename = state.file.filename; const fileCriticalConflicts = fileConflictMap.get(filename) ?? []; const fileAttrDuplicates = fileAttributeDuplicateMap.get(filename) ?? []; const fileEnumConflicts = fileEnumConflictMap.get(filename) ?? []; const filePermissionConflicts = filePermissionConflictMap.get(filename) ?? []; const fileStateFieldConflicts = fileStateFieldConflictMap.get(filename) ?? []; const fileErrorCodeConflicts = fileErrorCodeConflictMap.get(filename) ?? []; const fileOversizedToc = oversizedTocMap.get(fileIndex) ?? []; const fileProseConflicts = fileProseConflictMap.get(filename) ?? []; const fileDecisionConflicts = fileDecisionConflictMap.get(filename) ?? []; const hasCriticalConflict = fileCriticalConflicts.length > 0 || fileAttrDuplicates.length > 0 || fileEnumConflicts.length > 0 || filePermissionConflicts.length > 0 || fileStateFieldConflicts.length > 0 || fileErrorCodeConflicts.length > 0 || fileOversizedToc.length > 0 || fileProseConflicts.length > 0 || fileDecisionConflicts.length > 0; // Decision logic: // 1. per-file reject → reject (unchanged) // 2. per-file approve + critical conflict detected → reject (NEW: patch-first) // 3. per-file approve + no critical conflict → approve (unchanged) const approved = perFileApproved && !hasCriticalConflict; const structuredCrossFileIssues = collectStructuredReviewIssues(crossFileResult); const programmaticIssues = buildProgrammaticSectionIssues({ fileCriticalConflicts, fileAttrDuplicates, fileEnumConflicts, filePermissionConflicts, fileStateFieldConflicts, fileErrorCodeConflicts, fileOversizedToc, fileProseConflicts, fileDecisionConflicts, }); if (approved) { // NOTE: revisedSections intentionally ignored — approved means pass as-is. // Applying revisedSections caused infinite re-write loops (sections kept growing). // Pass cross-file feedback as advisory for next retry's context if (!crossFileApproved && crossFileResult?.feedback) { state.sectionFeedback = `[Cross-file advisory] ${crossFileResult.feedback}`; } state.sectionRetryCount = 0; state.sectionStagnationCount = 0; state.lastSectionContentSignature = undefined; state.lastSectionRejectionSignature = undefined; pendingIndices.delete(fileIndex); } else { // Critical conflict rejected (per-file approved but programmatic violations exist) // Use cross-file rejectedModuleUnits for targeted patch if available state.sectionFeedback = formatStructuredIssuesForRetry({ fallbackFeedback: `[Critical conflict] ${[ ...fileCriticalConflicts, ...fileAttrDuplicates, ...fileEnumConflicts, ...fileProseConflicts, ...fileDecisionConflicts, ].join("; ")}` + (crossFileResult?.feedback ? `\n${crossFileResult.feedback}` : ""), issues: [...programmaticIssues, ...structuredCrossFileIssues], }); state.rejectedModuleUnits = normalizeRejectedModuleUnits( crossFileResult?.rejectedModuleUnits ?? null, [...programmaticIssues, ...structuredCrossFileIssues], ); // Fallback: infer targets from issues to avoid full-file rewrite if (state.rejectedModuleUnits === null) { state.rejectedModuleUnits = inferRejectedModuleUnitsFromIssues( [...programmaticIssues, ...structuredCrossFileIssues], state.unitResults!, ); } analyzeDebug( `section reject file="${state.file.filename}" attempt=${attempt} perFileApproved=${perFileApproved} crossFileApproved=${crossFileApproved} critical=${hasCriticalConflict} targets=${formatRejectedModuleUnitsSummary( state.rejectedModuleUnits, )} issues=${formatReviewIssuesSummary([ ...programmaticIssues, ...structuredCrossFileIssues, ])} feedback=${truncateForDebug(state.sectionFeedback ?? "", 500)}`, ); } if (!approved) { const contentSignature = buildSectionContentSignature(state); const rejectionSignature = buildSectionRejectionSignature({ rejectedModuleUnits: state.rejectedModuleUnits ?? null, feedback: state.sectionFeedback ?? "", }); const isStagnant = state.lastSectionContentSignature === contentSignature && state.lastSectionRejectionSignature === rejectionSignature; state.sectionStagnationCount = isStagnant ? (state.sectionStagnationCount ?? 0) + 1 : 0; state.sectionRetryCount = (state.sectionRetryCount ?? 0) + 1; state.lastSectionContentSignature = contentSignature; state.lastSectionRejectionSignature = rejectionSignature; if ((state.sectionRetryCount ?? 0) > ANALYZE_SECTION_FILE_MAX_RETRY) { analyzeDebug( `[orchestrateAnalyze] Section stage: force-passing (max retry exceeded: ${ANALYZE_SECTION_FILE_MAX_RETRY}) for file "${state.file.filename}"`, ); pendingIndices.delete(fileIndex); continue; } if ( (state.sectionStagnationCount ?? 0) >= ANALYZE_SECTION_STAGNATION_MAX ) { analyzeDebug( `[orchestrateAnalyze] Section stage: force-passing (stagnation detected ${state.sectionStagnationCount}x) for file "${state.file.filename}"`, ); pendingIndices.delete(fileIndex); continue; } } } } if (pendingIndices.size > 0) { analyzeDebug( `[orchestrateAnalyze] Section stage: force-passing after max retries for files: ${[ ...pendingIndices, ] .map((i) => props.fileStates[i]!.file.filename) .join(", ")}`, ); } } // ─── Section-stage helper functions ─── function computeSectionBatchSize(props: { attempt: number; pendingCount: number; }): number { return Math.min(8, props.pendingCount); } function chunkSectionFileIndices(indices: number[], size: number): number[][] { if (indices.length === 0) return []; if (size <= 0 || size >= indices.length) return [indices]; const chunks: number[][] = []; for (let i = 0; i < indices.length; i += size) chunks.push(indices.slice(i, i + size)); return chunks; } function buildRejectedSet( rejected: AutoBeAnalyzeSectionReviewRejectedModuleUnit[] | null | undefined, ): Set<string> | null { if (rejected == null) return null; if (rejected.length === 0) return null; const set: Set<string> = new Set(); for (const entry of rejected) { for (const ui of entry.unitIndices) { set.add(`${entry.moduleIndex}:${ui}`); } } return set.size > 0 ? set : null; } interface ISectionAwareFeedback { feedback: string; sectionIndices: number[] | null; } function buildFeedbackMap( rejected: AutoBeAnalyzeSectionReviewRejectedModuleUnit[] | null | undefined, ): Map<string, ISectionAwareFeedback> { const map: Map<string, ISectionAwareFeedback> = new Map(); if (rejected == null) return map; for (const entry of rejected) { for (const ui of entry.unitIndices) { map.set(`${entry.moduleIndex}:${ui}`, { feedback: formatRejectedModuleUnitFeedback(entry, ui), sectionIndices: entry.sectionIndicesPerUnit?.[ui] ?? null, }); } } return map; } function isSectionRejected( rejectedSet: Set<string> | null, moduleIndex: number, unitIndex: number, ): boolean { if (rejectedSet === null) return true; return rejectedSet.has(`${moduleIndex}:${unitIndex}`); } function filterValidFileResults<T extends { fileIndex: number }>( fileResults: T[], fileCount: number, stage: string, ): T[] { return fileResults.filter((fr) => { if ( Number.isInteger(fr.fileIndex) && fr.fileIndex >= 0 && fr.fileIndex < fileCount ) { return true; } console.warn( `[orchestrateAnalyze] ${stage}: invalid fileIndex ${fr.fileIndex} (valid: 0-${fileCount - 1})`, ); return false; }); } function formatRejectedModuleUnitFeedback( entry: AutoBeAnalyzeSectionReviewRejectedModuleUnit, unitIndex: number, ): string { const scopedIssues = (entry.issues ?? []).filter( (issue) => issue.moduleIndex === entry.moduleIndex && (issue.unitIndex === null || issue.unitIndex === unitIndex), ); if (scopedIssues.length === 0) return entry.feedback; return [ entry.feedback, ...scopedIssues.map( (issue) => `- [${issue.ruleCode}] target=${formatIssueTarget(issue)} fix=${issue.fixInstruction}`, ), ].join("\n"); } function collectStructuredReviewIssues( result: | { feedback: string; rejectedModuleUnits?: | AutoBeAnalyzeSectionReviewRejectedModuleUnit[] | null; issues?: AutoBeAnalyzeSectionReviewIssue[] | null; } | undefined, ): AutoBeAnalyzeSectionReviewIssue[] { if (!result) return []; const collected: AutoBeAnalyzeSectionReviewIssue[] = []; for (const issue of result.issues ?? []) collected.push(issue); for (const group of result.rejectedModuleUnits ?? []) { for (const issue of group.issues ?? []) collected.push(issue); if ((group.issues?.length ?? 0) === 0) { for (const unitIndex of group.unitIndices) { collected.push({ ruleCode: "section_review_reject", moduleIndex: group.moduleIndex, unitIndex, fixInstruction: group.feedback || result.feedback || "Fix review issues.", evidence: null, }); } } } if (collected.length === 0 && result.feedback.trim().length > 0) { collected.push({ ruleCode: "section_review_reject", moduleIndex: null, unitIndex: null, fixInstruction: result.feedback, evidence: null, }); } return dedupeReviewIssues(collected); } function buildProgrammaticSectionIssues(props: { fileCriticalConflicts: string[]; fileAttrDuplicates: string[]; fileEnumConflicts: string[]; filePermissionConflicts: string[]; fileStateFieldConflicts: string[]; fileErrorCodeConflicts: string[]; fileOversizedToc: string[]; fileProseConflicts: string[]; fileDecisionConflicts: string[]; }): AutoBeAnalyzeSectionReviewIssue[] { return [ ...props.fileCriticalConflicts.map((detail) => ({ ruleCode: "cross_file_constraint_conflict", moduleIndex: null, unitIndex: null, fixInstruction: "Align conflicting constraints/values with other files and preserve one canonical value.", evidence: detail, })), ...props.fileAttrDuplicates.map((detail) => ({ ruleCode: "cross_file_attribute_duplicate", moduleIndex: null, unitIndex: null, fixInstruction: "Remove duplicate attribute specifications across files and keep ownership in one file.", evidence: detail, })), ...props.fileEnumConflicts.map((detail) => ({ ruleCode: "cross_file_enum_conflict", moduleIndex: null, unitIndex: null, fixInstruction: "Align enum values with the canonical definition from the first file that specified this attribute. Use the exact same enum set.", evidence: detail, })), ...props.filePermissionConflicts.map((detail) => ({ ruleCode: "cross_file_permission_conflict", moduleIndex: null, unitIndex: null, fixInstruction: "Align permission rules with the canonical definition. If the first file says 'denied', all files must say 'denied' for the same actor→operation.", evidence: detail, })), ...props.fileStateFieldConflicts.map((detail) => ({ ruleCode: "cross_file_state_field_conflict", moduleIndex: null, unitIndex: null, fixInstruction: "Use ONE canonical approach for state fields. If other files use 'deletedAt: datetime', do NOT use 'isDeleted: boolean'. Pick one and align.", evidence: detail, })), ...props.fileErrorCodeConflicts.map((detail) => ({ ruleCode: "cross_file_error_code_conflict", moduleIndex: null, unitIndex: null, fixInstruction: "Use the canonical error code defined in the first file. Do NOT invent alternative error codes for the same condition.", evidence: detail, })), ...props.fileOversizedToc.map((detail) => ({ ruleCode: "oversized_toc", moduleIndex: null, unitIndex: null, fixInstruction: "TOC must be a concise navigation aid. Remove detailed requirements, keep only navigation tables and brief summaries.", evidence: detail, })), ...props.fileProseConflicts.map((detail) => ({ ruleCode: "cross_file_prose_constraint_conflict", moduleIndex: null, unitIndex: null, fixInstruction: "Remove the restated constraint value and use a backtick reference to the canonical definition in 02-domain-model instead. Example: 'THE system SHALL validate `User.bio` per entity constraints (see 02-domain-model)'", evidence: detail, })), ...props.fileDecisionConflicts.map((detail) => ({ ruleCode: "cross_file_decision_conflict", moduleIndex: null, unitIndex: null, fixInstruction: "This file contradicts another file on a key behavioral decision. Align with the canonical source file for this topic.", evidence: detail, })), ]; } function buildSectionIndicesPerUnit( issues: AutoBeAnalyzeSectionReviewIssue[], moduleIndex: number, unitIndices: number[], ): Record<number, number[]> | null { const map: Record<number, Set<number>> = {}; let hasSectionLevel = false; for (const issue of issues) { if ( issue.moduleIndex === moduleIndex && issue.unitIndex !== null && unitIndices.includes(issue.unitIndex) && issue.sectionIndex !== null && issue.sectionIndex !== undefined && Number.isInteger(issue.sectionIndex) && issue.sectionIndex >= 0 ) { if (!map[issue.unitIndex]) map[issue.unitIndex] = new Set(); map[issue.unitIndex]!.add(issue.sectionIndex); hasSectionLevel = true; } } if (!hasSectionLevel) return null; const result: Record<number, number[]> = {}; for (const [ui, sectionSet] of Object.entries(map)) { result[Number(ui)] = [...sectionSet].sort((a, b) => a - b); } return result; } function normalizeRejectedModuleUnits( rejected: AutoBeAnalyzeSectionReviewRejectedModuleUnit[] | null | undefined, fileIssues: AutoBeAnalyzeSectionReviewIssue[], ): AutoBeAnalyzeSectionReviewRejectedModuleUnit[] | null { if (rejected == null) return null; return rejected.map((entry) => { const enrichedIssues = (entry.issues?.length ?? 0) > 0 ? dedupeReviewIssues(entry.issues ?? []) : dedupeReviewIssues( fileIssues.filter( (issue) => issue.moduleIndex === entry.moduleIndex && (issue.unitIndex === null || entry.unitIndices.includes(issue.unitIndex)), ), ); const sectionIndicesPerUnit = entry.sectionIndicesPerUnit ?? buildSectionIndicesPerUnit( enrichedIssues, entry.moduleIndex, entry.unitIndices, ); return { ...entry, issues: enrichedIssues, sectionIndicesPerUnit, }; }); } /** * Infer rejectedModuleUnits from structured issues when the LLM re