feat(validation): plan-compile gate + semantic-review gate (staged verification #19/#20)
Plan-compile gate (#19): PlanCompilationCheck seam (null-on-replay), runPlanCompileGate guards a produced execution_plan slot and compiles its cached content via ExecutionPlanCompiler, emitting PlanCompileCheckedEvent. On failure the architect gets a retryable hand-back and self-corrects; exhaustion terminates the dead-end workflow instead of shipping an uncompilable plan. Semantic-review gate (#20): SemanticReviewer seam + StageConfig.semanticReview opt-in (TOML semantic_review + freestyle plan JSON). runReviewGate reads the FileWritten manifest (never blind context), asks a review-capable model for PR-comment findings, and is advisory — blocks retryably only on FAIL plus a correctness finding >=0.7 confidence, capped at 2 blocks so a stubborn stage completes advisory-only rather than trapping. SemanticReviewerImpl talks to the provider directly; any reviewer error degrades to PASS. REVIEW_MAX_TOKENS is 8192: gemma is a reasoning model and 1024 truncated it mid-reasoning before it reached the JSON verdict (empty content -> parse degraded to PASS). Both gates live-QA'd on the real repo: plan-compile passed on a freestyle web-UI scaffold; semantic review caught all 3 planted bugs in a maxOf() and exercised the block->retry->cap safety valve end-to-end.
This commit is contained in:
@@ -71,6 +71,7 @@ import com.correx.core.toolintent.rules.ReferenceExistsRule
|
||||
import com.correx.core.toolintent.rules.StaleWriteRule
|
||||
import com.correx.core.toolintent.rules.WriteScopeRule
|
||||
import com.correx.apps.server.freestyle.FreestyleDriver
|
||||
import com.correx.apps.server.inference.SemanticReviewerImpl
|
||||
import com.correx.apps.server.inference.summarizeWithInference
|
||||
import com.correx.core.kernel.orchestration.JournalCompactionService
|
||||
import com.correx.apps.server.personalization.ProfileAdaptationService
|
||||
@@ -326,6 +327,7 @@ fun main() {
|
||||
workspaceToolRegistryProvider = wsToolRegistryProvider,
|
||||
staticAnalysisRunner = ProcessStaticAnalysisRunner(),
|
||||
contractAssertionEvaluator = FileSystemContractEvaluator(),
|
||||
semanticReviewer = SemanticReviewerImpl(inferenceRouter),
|
||||
)
|
||||
val decisionJournalRepository = InfrastructureModule.createDecisionJournalRepository(eventStore)
|
||||
val defaultOrchestrationConfig = OrchestrationConfig(
|
||||
@@ -366,9 +368,17 @@ fun main() {
|
||||
// task_context tool and GET /tasks/{id}/context return semantically-relevant snippets.
|
||||
taskKnowledgeRetriever.delegate =
|
||||
repoKnowledgeRetriever?.let { com.correx.apps.server.memory.RepoKnowledgeTaskRetriever(it) }
|
||||
// Plan-compile gate: wrap the ExecutionPlanCompiler as a post-stage check so a compile-invalid
|
||||
// architect plan is handed back through the retry-feedback loop instead of parking the session in
|
||||
// ACTIVE (post-planning compile dead-end). Returns null on success, the compiler message on failure.
|
||||
val planCompiler = ExecutionPlanCompiler(artifactKindRegistry, toolRegistry.all().map { it.name }.toSet())
|
||||
val orchestrator = DefaultSessionOrchestrator(
|
||||
repositories = repositories,
|
||||
engines = engines,
|
||||
engines = engines.copy(
|
||||
planCompilationCheck = { json, candidateId ->
|
||||
runCatching { planCompiler.compile(json, candidateId) }.exceptionOrNull()?.message
|
||||
},
|
||||
),
|
||||
retryCoordinator = DefaultRetryCoordinator(eventStore),
|
||||
artifactStore = artifactStore,
|
||||
tokenizer = firstProvider.tokenizer,
|
||||
@@ -491,7 +501,7 @@ fun main() {
|
||||
|
||||
val freestyleDriver = FreestyleDriver(
|
||||
eventStore = eventStore,
|
||||
compiler = ExecutionPlanCompiler(artifactKindRegistry, toolRegistry.all().map { it.name }.toSet()),
|
||||
compiler = planCompiler,
|
||||
planContent = { sid -> orchestrator.validatedArtifactContent(sid, ArtifactId("execution_plan")) },
|
||||
config = defaultOrchestrationConfig,
|
||||
runPhase2 = { sid, graph, cfg -> orchestrator.run(sid, graph, cfg) },
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package com.correx.apps.server.inference
|
||||
|
||||
import com.correx.core.context.model.CompressionMetadata
|
||||
import com.correx.core.context.model.ContextEntry
|
||||
import com.correx.core.context.model.ContextLayer
|
||||
import com.correx.core.context.model.ContextPack
|
||||
import com.correx.core.context.model.EntryRole
|
||||
import com.correx.core.events.events.ReviewFinding
|
||||
import com.correx.core.events.events.ReviewSeverity
|
||||
import com.correx.core.events.events.ReviewVerdict
|
||||
import com.correx.core.events.types.ContextEntryId
|
||||
import com.correx.core.events.types.ContextPackId
|
||||
import com.correx.core.events.types.InferenceRequestId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.inference.GenerationConfig
|
||||
import com.correx.core.inference.InferenceRequest
|
||||
import com.correx.core.inference.InferenceRouter
|
||||
import com.correx.core.inference.ModelCapability
|
||||
import com.correx.core.inference.ResponseFormat
|
||||
import com.correx.core.kernel.orchestration.ReviewOutcome
|
||||
import com.correx.core.kernel.orchestration.SemanticReviewer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.nio.file.Path
|
||||
import java.util.UUID
|
||||
import kotlin.io.path.readText
|
||||
|
||||
private const val MAX_FILE_CHARS = 8_000
|
||||
private const val MAX_TOTAL_CHARS = 24_000
|
||||
// Reviewer runs on a reasoning-capable model (gemma) that spends a large token budget thinking
|
||||
// before it emits the final JSON verdict. 1024 truncated it mid-reasoning → empty content →
|
||||
// parse degraded to PASS. Give it real headroom so it reaches the JSON.
|
||||
private const val REVIEW_MAX_TOKENS = 8_192
|
||||
|
||||
/**
|
||||
* Gate 3 reviewer implementation: reads the stage's actually-produced files from the workspace (the
|
||||
* FileWritten manifest — never blind context), asks a review-capable model for PR-comment-style
|
||||
* findings as JSON, and maps them to [ReviewOutcome]. Talks to the provider directly with a
|
||||
* self-contained prompt (like [summarizeWithInference]) rather than the orchestrator's contextPack
|
||||
* machinery. Any failure (unroutable, timeout, unparseable output) degrades to a PASS with no
|
||||
* findings — the reviewer is advisory and must never wedge a stage on its own error.
|
||||
*/
|
||||
class SemanticReviewerImpl(
|
||||
private val inferenceRouter: InferenceRouter,
|
||||
private val modelId: String? = null,
|
||||
) : SemanticReviewer {
|
||||
|
||||
override suspend fun review(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
workspaceRoot: Path,
|
||||
files: List<String>,
|
||||
objective: String,
|
||||
): ReviewOutcome {
|
||||
val bundle = renderFiles(workspaceRoot, files)
|
||||
if (bundle.isBlank()) return ReviewOutcome(ReviewVerdict.PASS, emptyList())
|
||||
|
||||
return runCatching {
|
||||
val provider = inferenceRouter.route(stageId, setOf(ModelCapability.General), modelId)
|
||||
val prompt = buildPrompt(objective, bundle)
|
||||
val request = InferenceRequest(
|
||||
requestId = InferenceRequestId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
contextPack = pack(sessionId, stageId, prompt),
|
||||
generationConfig = GenerationConfig(temperature = 0.2, topP = 0.9, maxTokens = REVIEW_MAX_TOKENS),
|
||||
responseFormat = ResponseFormat.Text,
|
||||
)
|
||||
parse(provider.infer(request).text)
|
||||
}.getOrElse {
|
||||
log.warn(
|
||||
"[SemanticReviewer] review failed session={} stage={}: {}",
|
||||
sessionId.value, stageId.value, it.message,
|
||||
)
|
||||
ReviewOutcome(ReviewVerdict.PASS, emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderFiles(workspaceRoot: Path, files: List<String>): String {
|
||||
val sb = StringBuilder()
|
||||
for (rel in files) {
|
||||
if (sb.length >= MAX_TOTAL_CHARS) break
|
||||
val text = runCatching { workspaceRoot.resolve(rel).readText() }.getOrNull()
|
||||
if (text != null) {
|
||||
sb.append("=== FILE: ").append(rel).append(" ===\n")
|
||||
.append(text.take(MAX_FILE_CHARS)).append("\n\n")
|
||||
}
|
||||
}
|
||||
return sb.toString().take(MAX_TOTAL_CHARS)
|
||||
}
|
||||
|
||||
private fun buildPrompt(objective: String, bundle: String): String =
|
||||
"""
|
||||
You are a senior code reviewer performing a PR review. The deterministic checks (compile,
|
||||
contract, build) have ALREADY passed — do NOT report anything a compiler or linter would catch.
|
||||
Review ONLY for correctness bugs, logic errors, security issues, and clear design/smell problems.
|
||||
|
||||
STAGE OBJECTIVE:
|
||||
$objective
|
||||
|
||||
FILES PRODUCED:
|
||||
$bundle
|
||||
|
||||
Respond with ONLY a JSON object, no prose, no markdown fence:
|
||||
{"verdict":"PASS|WARN|FAIL","findings":[
|
||||
{"severity":"CRITICAL|MAJOR|MINOR|NIT","confidence":0.0-1.0,"category":"correctness|smell|architecture|naming|security",
|
||||
"target":"path/to/file.ext","message":"what is wrong","suggestedFix":"how to fix (optional)","correctness":true|false}
|
||||
]}
|
||||
Set correctness=true only when the code is actually WRONG (a confirmable bug), false for
|
||||
subjective/suboptimal opinions. Use verdict FAIL only when at least one correctness finding is
|
||||
high-confidence. Empty findings + PASS when the code is sound.
|
||||
""".trimIndent()
|
||||
|
||||
private fun pack(sessionId: SessionId, stageId: StageId, prompt: String) = ContextPack(
|
||||
id = ContextPackId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
layers = mapOf(
|
||||
ContextLayer.L0 to listOf(
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L0,
|
||||
content = prompt,
|
||||
sourceType = "semantic_review",
|
||||
sourceId = "reviewer",
|
||||
tokenEstimate = prompt.length / 4,
|
||||
role = EntryRole.USER,
|
||||
),
|
||||
),
|
||||
),
|
||||
budgetUsed = prompt.length / 4,
|
||||
budgetLimit = prompt.length / 4,
|
||||
compressionMetadata = CompressionMetadata(),
|
||||
)
|
||||
|
||||
private fun parse(raw: String): ReviewOutcome {
|
||||
val pass = ReviewOutcome(ReviewVerdict.PASS, emptyList())
|
||||
val dto = extractJson(raw)
|
||||
?.let { json -> runCatching { lenient.decodeFromString(ReviewDto.serializer(), json) }.getOrNull() }
|
||||
?: return pass
|
||||
val verdict = runCatching { ReviewVerdict.valueOf(dto.verdict.uppercase()) }.getOrDefault(ReviewVerdict.PASS)
|
||||
val findings = dto.findings.map { f ->
|
||||
ReviewFinding(
|
||||
severity = runCatching { ReviewSeverity.valueOf(f.severity.uppercase()) }
|
||||
.getOrDefault(ReviewSeverity.MINOR),
|
||||
confidence = f.confidence.coerceIn(0.0, 1.0),
|
||||
category = f.category.ifBlank { "correctness" },
|
||||
target = f.target,
|
||||
message = f.message,
|
||||
suggestedFix = f.suggestedFix?.takeIf { it.isNotBlank() },
|
||||
correctness = f.correctness,
|
||||
)
|
||||
}
|
||||
return ReviewOutcome(verdict, findings)
|
||||
}
|
||||
|
||||
/** Strip an optional ```json fence and slice from the first { to the last } — tolerant of prose wrap. */
|
||||
private fun extractJson(raw: String): String? {
|
||||
val start = raw.indexOf('{')
|
||||
val end = raw.lastIndexOf('}')
|
||||
return if (start in 0 until end) raw.substring(start, end + 1) else null
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class ReviewDto(
|
||||
val verdict: String = "PASS",
|
||||
val findings: List<FindingDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class FindingDto(
|
||||
val severity: String = "MINOR",
|
||||
val confidence: Double = 0.5,
|
||||
val category: String = "correctness",
|
||||
val target: String = "",
|
||||
val message: String = "",
|
||||
@SerialName("suggestedFix") val suggestedFix: String? = null,
|
||||
val correctness: Boolean = false,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(SemanticReviewerImpl::class.java)
|
||||
private val lenient = Json { ignoreUnknownKeys = true; isLenient = true }
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.correx.core.events.events
|
||||
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@@ -34,3 +35,20 @@ data class ExecutionPlanRejectedEvent(
|
||||
/** "compile" | "missing_content" | "operator" */
|
||||
val source: String,
|
||||
) : EventPayload
|
||||
|
||||
/**
|
||||
* Environment observation (invariant #9): the outcome of compiling a stage's produced execution_plan
|
||||
* as a post-stage gate. Recorded at the moment the architect stage completes so replay reads the
|
||||
* recorded verdict and never re-compiles. A non-null [error] fails the producing stage retryably,
|
||||
* feeding the compiler message back so the architect self-corrects via the retry-feedback loop
|
||||
* (rather than parking the session in ACTIVE forever, as a post-planning compile failure did).
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("PlanCompileChecked")
|
||||
data class PlanCompileCheckedEvent(
|
||||
val sessionId: SessionId,
|
||||
val stageId: StageId,
|
||||
val passed: Boolean,
|
||||
/** Compiler error when [passed] is false; null on success. Kept bounded. */
|
||||
val error: String? = null,
|
||||
) : EventPayload
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.correx.core.events.events
|
||||
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** Overall verdict of a semantic (LLM) review over a stage's produced files. */
|
||||
enum class ReviewVerdict { PASS, WARN, FAIL }
|
||||
|
||||
/** Severity of a single review finding, high→low. */
|
||||
enum class ReviewSeverity { CRITICAL, MAJOR, MINOR, NIT }
|
||||
|
||||
/**
|
||||
* One PR-comment-style finding from the semantic reviewer (Gate 3). A finding is a proposal, not a
|
||||
* fact — [confidence] is the reviewer's own 0..1 self-estimate and [correctness] marks findings that
|
||||
* claim the code is *wrong* (deterministically re-confirmable) vs. merely *suboptimal* (a smell /
|
||||
* style / architecture opinion that can never block on its own).
|
||||
*/
|
||||
@Serializable
|
||||
data class ReviewFinding(
|
||||
val severity: ReviewSeverity,
|
||||
/** Reviewer self-estimated confidence, 0.0..1.0. */
|
||||
val confidence: Double,
|
||||
/** Short category slug, e.g. "correctness", "smell", "architecture", "naming". */
|
||||
val category: String,
|
||||
/** Workspace-relative file (optionally :line) the finding anchors to. */
|
||||
val target: String,
|
||||
/** What is wrong / suboptimal. Kept bounded by the producer. */
|
||||
val message: String,
|
||||
/** Suggested fix, when the reviewer offers one. */
|
||||
val suggestedFix: String? = null,
|
||||
/** True = claims the code is incorrect (confirmable); false = suboptimal opinion (never blocks). */
|
||||
val correctness: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Environment observation (invariant #9): the semantic reviewer's findings over a stage's produced
|
||||
* files, captured at the moment the stage completed. The reviewer is advisory — it reads the actual
|
||||
* FileWritten manifest (never blind context) and its findings surface to the operator regardless of
|
||||
* verdict. A [ReviewVerdict.FAIL] carrying a high-confidence correctness finding can block the stage
|
||||
* retryably, but only until the review-block retry budget is spent, after which findings surface
|
||||
* without blocking. Replay reads these recorded facts and never re-runs the reviewer.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("ReviewFindingsRaised")
|
||||
data class ReviewFindingsRaisedEvent(
|
||||
val sessionId: SessionId,
|
||||
val stageId: StageId,
|
||||
val verdict: ReviewVerdict,
|
||||
val findings: List<ReviewFinding>,
|
||||
/** True when this review's FAIL verdict blocked the stage (fed back for a fix); false = advisory. */
|
||||
val blocked: Boolean = false,
|
||||
) : EventPayload
|
||||
@@ -33,6 +33,8 @@ import com.correx.core.events.events.PossibleContradictionFlaggedEvent
|
||||
import com.correx.core.events.events.EgressHostsGrantedEvent
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.ExecutionPlanLockedEvent
|
||||
import com.correx.core.events.events.PlanCompileCheckedEvent
|
||||
import com.correx.core.events.events.ReviewFindingsRaisedEvent
|
||||
import com.correx.core.events.events.ExecutionPlanRejectedEvent
|
||||
import com.correx.core.events.events.HealthDegradedEvent
|
||||
import com.correx.core.events.events.HealthRestoredEvent
|
||||
@@ -164,6 +166,8 @@ val eventModule = SerializersModule {
|
||||
subclass(ContextTruncatedEvent::class)
|
||||
subclass(ExecutionPlanLockedEvent::class)
|
||||
subclass(ExecutionPlanRejectedEvent::class)
|
||||
subclass(PlanCompileCheckedEvent::class)
|
||||
subclass(ReviewFindingsRaisedEvent::class)
|
||||
subclass(JournalCompactedEvent::class)
|
||||
subclass(HealthDegradedEvent::class)
|
||||
subclass(HealthRestoredEvent::class)
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package com.correx.core.events.serialization
|
||||
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.PlanCompileCheckedEvent
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class PlanCompileCheckedEventSerializationTest {
|
||||
|
||||
@Test
|
||||
fun `PlanCompileCheckedEvent round-trips as polymorphic EventPayload`() {
|
||||
val sample: EventPayload = PlanCompileCheckedEvent(
|
||||
sessionId = SessionId("s"),
|
||||
stageId = StageId("architect"),
|
||||
passed = false,
|
||||
error = "edge from unknown stage 'ghost'",
|
||||
)
|
||||
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
|
||||
assertTrue(encoded.contains("\"type\":\"PlanCompileChecked\""), "SerialName must be present: $encoded")
|
||||
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
|
||||
assertEquals(sample, decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PlanCompileCheckedEvent round-trips on success with null error`() {
|
||||
val sample: EventPayload = PlanCompileCheckedEvent(
|
||||
sessionId = SessionId("s"),
|
||||
stageId = StageId("architect"),
|
||||
passed = true,
|
||||
)
|
||||
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
|
||||
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
|
||||
assertEquals(sample, decoded)
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.correx.core.events.serialization
|
||||
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.ReviewFinding
|
||||
import com.correx.core.events.events.ReviewFindingsRaisedEvent
|
||||
import com.correx.core.events.events.ReviewSeverity
|
||||
import com.correx.core.events.events.ReviewVerdict
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ReviewFindingsRaisedEventSerializationTest {
|
||||
|
||||
@Test
|
||||
fun `ReviewFindingsRaisedEvent round-trips as polymorphic EventPayload`() {
|
||||
val sample: EventPayload = ReviewFindingsRaisedEvent(
|
||||
sessionId = SessionId("s"),
|
||||
stageId = StageId("implement"),
|
||||
verdict = ReviewVerdict.FAIL,
|
||||
findings = listOf(
|
||||
ReviewFinding(
|
||||
severity = ReviewSeverity.CRITICAL,
|
||||
confidence = 0.9,
|
||||
category = "correctness",
|
||||
target = "src/App.tsx:42",
|
||||
message = "off-by-one in the loop bound",
|
||||
suggestedFix = "use < instead of <=",
|
||||
correctness = true,
|
||||
),
|
||||
),
|
||||
blocked = true,
|
||||
)
|
||||
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
|
||||
assertTrue(encoded.contains("\"type\":\"ReviewFindingsRaised\""), "SerialName must be present: $encoded")
|
||||
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
|
||||
assertEquals(sample, decoded)
|
||||
}
|
||||
}
|
||||
+9
@@ -34,4 +34,13 @@ data class OrchestratorEngines(
|
||||
// Evaluates Gate 2 contract assertions against a stage's produced files (design §1). Null = no
|
||||
// contract gate; the deterministic funnel then rests on produces-presence + static analysis only.
|
||||
val contractAssertionEvaluator: ContractAssertionEvaluator? = null,
|
||||
// Compiles a stage's produced execution_plan as a post-stage gate (plan-compile gate). Null = no
|
||||
// gate; a compile-invalid plan then falls through to the post-planning compiler, which historically
|
||||
// parked the session in ACTIVE. Wired, it fails the architect stage retryably so the existing
|
||||
// retry-feedback loop hands the compiler error back for self-correction.
|
||||
val planCompilationCheck: PlanCompilationCheck? = null,
|
||||
// Gate 3 semantic (LLM) reviewer, run last over a stage's static-clean produced files (design §
|
||||
// Gate 3). Null = no semantic review; the funnel then rests on the deterministic gates only. A
|
||||
// stage opts in via StageConfig.semanticReview.
|
||||
val semanticReviewer: SemanticReviewer? = null,
|
||||
)
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
/**
|
||||
* Seam for compiling a freestyle execution_plan as a post-stage gate. Like [ContractAssertionEvaluator]
|
||||
* and [StaticAnalysisRunner], the real implementation (which wraps the infrastructure
|
||||
* ExecutionPlanCompiler) is injected, so the deterministic core never depends on infrastructure and
|
||||
* the gate stays unit-testable with a fake.
|
||||
*
|
||||
* [check] returns null when the plan compiles, or a bounded compiler-error string when it does not.
|
||||
* The orchestrator records the verdict in a
|
||||
* [com.correx.core.events.events.PlanCompileCheckedEvent] (invariant #9), and on failure fails the
|
||||
* architect stage retryably so the existing retry-feedback loop hands the error back and the model
|
||||
* self-corrects — replacing the post-planning compile failure that parked the session in ACTIVE.
|
||||
*/
|
||||
fun interface PlanCompilationCheck {
|
||||
fun check(planJson: String, candidateId: String): String?
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.events.events.ReviewFinding
|
||||
import com.correx.core.events.events.ReviewVerdict
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import java.nio.file.Path
|
||||
|
||||
/** Result of a semantic review over a stage's produced files. */
|
||||
data class ReviewOutcome(
|
||||
val verdict: ReviewVerdict,
|
||||
val findings: List<ReviewFinding>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Seam for the Gate 3 semantic (LLM) reviewer. Like the other gate seams
|
||||
* ([ContractAssertionEvaluator], [StaticAnalysisRunner], [PlanCompilationCheck]), the
|
||||
* inference-and-filesystem-touching implementation lives in apps/infrastructure and is injected, so
|
||||
* the deterministic core never runs inference or reads files itself and the gate stays
|
||||
* unit-testable with a fake.
|
||||
*
|
||||
* The implementation reads the actual produced [files] from [workspaceRoot] (the FileWritten
|
||||
* manifest — never blind context), reviews them against [objective], and returns PR-comment-style
|
||||
* findings. Runs last, after the deterministic funnel (contract/static/execution) has passed, so its
|
||||
* input is static-clean and its findings exclude anything the cheap gates already catch.
|
||||
*/
|
||||
fun interface SemanticReviewer {
|
||||
suspend fun review(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
workspaceRoot: Path,
|
||||
files: List<String>,
|
||||
objective: String,
|
||||
): ReviewOutcome
|
||||
}
|
||||
+121
@@ -35,6 +35,9 @@ import com.correx.core.events.events.ApprovalRequestedEvent
|
||||
import com.correx.core.events.events.ArtifactContentStoredEvent
|
||||
import com.correx.core.events.events.FileWrittenEvent
|
||||
import com.correx.core.events.events.ContractGateEvaluatedEvent
|
||||
import com.correx.core.events.events.PlanCompileCheckedEvent
|
||||
import com.correx.core.events.events.ReviewFindingsRaisedEvent
|
||||
import com.correx.core.events.events.ReviewVerdict
|
||||
import com.correx.core.events.events.ContractAssertionResult
|
||||
import com.correx.core.events.events.ArtifactCreatedEvent
|
||||
import com.correx.core.events.events.ArtifactRepairAttemptedEvent
|
||||
@@ -220,6 +223,13 @@ private const val MAX_CLARIFICATION_ROUNDS = 3
|
||||
// Static-analysis output caps: the tail retained in the recorded event vs. the (larger) slice fed
|
||||
// back to the model so it can fix the failure. Tails, because the error summary sits at the end.
|
||||
private const val CONTRACT_EVIDENCE_CAP = 400
|
||||
// Gate 3 semantic review: a FAIL blocks only on a correctness finding at/above this confidence, and
|
||||
// only until this many prior blocks have occurred for the stage — after which findings surface
|
||||
// without blocking, so a stuck reviewer can never trap a stage. Objective text fed to the reviewer
|
||||
// is capped to keep the review prompt bounded.
|
||||
private const val REVIEW_BLOCK_MIN_CONFIDENCE = 0.7
|
||||
private const val REVIEW_BLOCK_RETRY_CAP = 2
|
||||
private const val REVIEW_OBJECTIVE_CAP = 4_000
|
||||
private const val STATIC_ANALYSIS_SUMMARY_CAP = 2_000
|
||||
private const val STATIC_ANALYSIS_FEEDBACK_CAP = 6_000
|
||||
|
||||
@@ -258,6 +268,8 @@ abstract class SessionOrchestrator(
|
||||
private val worldProbe: WorldProbe = engines.worldProbe
|
||||
private val staticAnalysisRunner: StaticAnalysisRunner? = engines.staticAnalysisRunner
|
||||
private val contractAssertionEvaluator: ContractAssertionEvaluator? = engines.contractAssertionEvaluator
|
||||
private val planCompilationCheck: PlanCompilationCheck? = engines.planCompilationCheck
|
||||
private val semanticReviewer: SemanticReviewer? = engines.semanticReviewer
|
||||
private val workspaceToolRegistryProvider: WorkspaceToolRegistryProvider? = engines.workspaceToolRegistryProvider
|
||||
private val inferenceRepository: InferenceRepository = repositories.inferenceRepository
|
||||
internal val orchestrationRepository: OrchestrationRepository = repositories.orchestrationRepository
|
||||
@@ -2093,8 +2105,10 @@ abstract class SessionOrchestrator(
|
||||
{ groundBriefReferences(sessionId, stageId, stageConfig, effectives) },
|
||||
{ checkBriefEcho(sessionId, stageId, stageConfig) },
|
||||
{ runContractGate(sessionId, stageId, stageConfig, effectives) },
|
||||
{ runPlanCompileGate(sessionId, stageId, stageConfig) },
|
||||
{ runStaticAnalysis(sessionId, stageId, stageConfig, effectives) },
|
||||
{ runExecutionGate(sessionId, stageId, stageConfig, effectives, profileCommands) },
|
||||
{ runReviewGate(sessionId, stageId, stageConfig, effectives) },
|
||||
)
|
||||
for (gate in gates) {
|
||||
val result = gate()
|
||||
@@ -2163,6 +2177,41 @@ abstract class SessionOrchestrator(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan-compile gate: for a stage that produces an `execution_plan` artifact (the freestyle
|
||||
* architect), compile its produced plan JSON as a post-stage gate. A compile failure fails the
|
||||
* stage retryably with the compiler error fed back, so the existing retry-feedback loop hands it
|
||||
* to the architect and the model self-corrects — rather than the post-planning compiler rejecting
|
||||
* a broken plan with no re-plan path (which parked the session in ACTIVE forever). The verdict is
|
||||
* recorded in a [PlanCompileCheckedEvent] (invariant #9) so replay reads it back and never
|
||||
* re-compiles.
|
||||
*/
|
||||
private suspend fun runPlanCompileGate(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
stageConfig: StageConfig,
|
||||
): StageExecutionResult {
|
||||
val check = planCompilationCheck ?: return StageExecutionResult.Success(emptyList())
|
||||
val planSlot = stageConfig.produces.firstOrNull { it.kind.id == "execution_plan" }
|
||||
?: return StageExecutionResult.Success(emptyList())
|
||||
val json = artifactContentCache["${sessionId.value}:${planSlot.name.value}"]
|
||||
?: return StageExecutionResult.Success(emptyList())
|
||||
|
||||
val error = check.check(json, "freestyle-${sessionId.value}")
|
||||
emit(sessionId, PlanCompileCheckedEvent(sessionId, stageId, passed = error == null, error = error))
|
||||
if (error == null) return StageExecutionResult.Success(emptyList())
|
||||
|
||||
log.warn(
|
||||
"[Orchestrator] plan-compile gate failed session={} stage={}: {}",
|
||||
sessionId.value, stageId.value, error,
|
||||
)
|
||||
return StageExecutionResult.Failure(
|
||||
"stage ${stageId.value} produced an execution_plan that does not compile. " +
|
||||
"Fix the plan before it can be locked:\n$error",
|
||||
retryable = true,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspace-relative paths this stage actually wrote, projected from events (invariant #1):
|
||||
* ToolInvocationRequested → this stage's invocation ids, FileWrittenEvent → the paths written
|
||||
@@ -2284,6 +2333,78 @@ abstract class SessionOrchestrator(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate 3 — semantic (LLM) review (staged-verification § Gate 3). For a stage that opts in
|
||||
* (`semanticReview`), the injected [SemanticReviewer] reads the files the stage actually wrote
|
||||
* (the FileWritten manifest — never blind context) and reviews them against the stage objective,
|
||||
* returning PR-comment-style findings. Runs last, so its input is static-clean (the deterministic
|
||||
* funnel already passed) and its findings exclude anything the cheap gates catch.
|
||||
*
|
||||
* The reviewer is advisory: every finding is recorded in a [ReviewFindingsRaisedEvent] and
|
||||
* surfaces to the operator regardless of verdict. A [ReviewVerdict.FAIL] blocks the stage
|
||||
* retryably ONLY when it carries a high-confidence *correctness* finding and the review-block
|
||||
* budget ([REVIEW_BLOCK_RETRY_CAP]) is not yet spent — so a stuck reviewer can never trap the
|
||||
* stage; after the budget, findings surface without blocking. Suboptimal/opinion findings never
|
||||
* block. Recorded as an environment observation (invariant #9); replay never re-runs the reviewer.
|
||||
*/
|
||||
private suspend fun runReviewGate(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
stageConfig: StageConfig,
|
||||
effectives: RunEffectives,
|
||||
): StageExecutionResult {
|
||||
if (!stageConfig.semanticReview) return StageExecutionResult.Success(emptyList())
|
||||
val reviewer = semanticReviewer ?: return StageExecutionResult.Success(emptyList())
|
||||
val workspaceRoot = effectives.policy?.workspaceRoot ?: return StageExecutionResult.Success(emptyList())
|
||||
val files = stageWrittenPaths(sessionId, stageId)
|
||||
if (files.isEmpty()) return StageExecutionResult.Success(emptyList())
|
||||
|
||||
val objective = stageConfig.needs
|
||||
.mapNotNull { artifactContentCache["${sessionId.value}:${it.value}"] }
|
||||
.joinToString("\n\n")
|
||||
.ifBlank { "Stage '${stageId.value}' produced the files below; review them for defects." }
|
||||
.take(REVIEW_OBJECTIVE_CAP)
|
||||
|
||||
val outcome = reviewer.review(sessionId, stageId, workspaceRoot, files, objective)
|
||||
|
||||
// A FAIL blocks only on a high-confidence correctness finding, and only while the review-block
|
||||
// budget remains — count prior review blocks for this stage from the log (replay-safe).
|
||||
val priorBlocks = eventStore.read(sessionId)
|
||||
.mapNotNull { it.payload as? ReviewFindingsRaisedEvent }
|
||||
.count { it.stageId == stageId && it.blocked }
|
||||
val blockingFinding = outcome.findings.firstOrNull {
|
||||
it.correctness && it.confidence >= REVIEW_BLOCK_MIN_CONFIDENCE
|
||||
}
|
||||
val shouldBlock = outcome.verdict == ReviewVerdict.FAIL &&
|
||||
blockingFinding != null &&
|
||||
priorBlocks < REVIEW_BLOCK_RETRY_CAP
|
||||
|
||||
emit(sessionId, ReviewFindingsRaisedEvent(sessionId, stageId, outcome.verdict, outcome.findings, blocked = shouldBlock))
|
||||
|
||||
if (!shouldBlock) {
|
||||
if (outcome.findings.isNotEmpty()) {
|
||||
log.info(
|
||||
"[Orchestrator] semantic review advisory session={} stage={} verdict={} findings={}",
|
||||
sessionId.value, stageId.value, outcome.verdict, outcome.findings.size,
|
||||
)
|
||||
}
|
||||
return StageExecutionResult.Success(emptyList())
|
||||
}
|
||||
|
||||
log.warn(
|
||||
"[Orchestrator] semantic review blocked session={} stage={} finding={}",
|
||||
sessionId.value, stageId.value, blockingFinding?.target,
|
||||
)
|
||||
val detail = outcome.findings
|
||||
.filter { it.correctness }
|
||||
.joinToString("\n") { "- [${it.severity}] ${it.target}: ${it.message}" +
|
||||
(it.suggestedFix?.let { fix -> "\n fix: $fix" } ?: "") }
|
||||
return StageExecutionResult.Failure(
|
||||
"stage ${stageId.value} failed semantic review — fix these correctness issues:\n$detail",
|
||||
retryable = true,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun emitProcessResultEvents(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
|
||||
@@ -38,5 +38,10 @@ data class StageConfig(
|
||||
// FAILS the gate — catching missing files, which the runtime write-manifest alone (it only
|
||||
// inspects files that WERE written) cannot. Derived from the concrete entries of PlanStage.writes.
|
||||
val expectedFiles: List<String> = emptyList(),
|
||||
// Opt-in to the Gate 3 semantic (LLM) reviewer for this stage (staged-verification § Gate 3). When
|
||||
// true and a reviewer is wired, the reviewer reads this stage's produced files after the
|
||||
// deterministic funnel passes and raises advisory PR-comment findings; a high-confidence
|
||||
// correctness FAIL can block retryably until the review-block budget is spent. Default off.
|
||||
val semanticReview: Boolean = false,
|
||||
val metadata: Map<String, String> = emptyMap(),
|
||||
)
|
||||
|
||||
+1
@@ -74,6 +74,7 @@ class ExecutionPlanCompiler(
|
||||
// not existence guarantees, so they are excluded here.
|
||||
expectedFiles = s.writes.map { it.trim() }.filter { it.isNotEmpty() && !isGlob(it) },
|
||||
buildExpectation = buildExpectation,
|
||||
semanticReview = s.semanticReview,
|
||||
tokenBudget = DEFAULT_STAGE_TOKEN_BUDGET,
|
||||
metadata = mapOf("promptInline" to s.prompt),
|
||||
)
|
||||
|
||||
+3
@@ -25,6 +25,9 @@ data class PlanStage(
|
||||
// planner emits this under the JSON key `build_expectation`; it resolves at runtime to the
|
||||
// project profile's typecheck/build/test command run as a deterministic Gate 4.
|
||||
@param:JsonProperty("build_expectation") val buildExpectation: String? = null,
|
||||
// Opt-in to the Gate 3 semantic (LLM) reviewer for this stage (staged-verification § Gate 3).
|
||||
// The planner emits this under the JSON key `semantic_review`; absent = off.
|
||||
@param:JsonProperty("semantic_review") val semanticReview: Boolean = false,
|
||||
)
|
||||
|
||||
data class PlanEdge(
|
||||
|
||||
+2
@@ -51,6 +51,7 @@ private data class StageSection(
|
||||
@param:JsonProperty("brief_echo") val briefEcho: Boolean = false,
|
||||
@param:JsonProperty("require_task_decompose") val requireTaskDecompose: Boolean = false,
|
||||
@param:JsonProperty("claim_task") val claimTask: Boolean = false,
|
||||
@param:JsonProperty("semantic_review") val semanticReview: Boolean = false,
|
||||
)
|
||||
|
||||
// condition fields flattened into the transition row
|
||||
@@ -110,6 +111,7 @@ class TomlWorkflowLoader(
|
||||
allowedTools = s.allowedTools.toSet(),
|
||||
writeManifest = s.writes,
|
||||
staticAnalysis = s.staticAnalysis,
|
||||
semanticReview = s.semanticReview,
|
||||
tokenBudget = s.tokenBudget,
|
||||
// Propagate the declared token budget to the inference completion cap.
|
||||
// Without this the StageConfig default (maxTokens=2048) is used, truncating
|
||||
|
||||
Reference in New Issue
Block a user