feat(kernel): label reviewer artifacts as critic feedback on back-edge re-entry

This commit is contained in:
2026-06-10 21:56:05 +04:00
parent 629b910e7f
commit beed501d60
3 changed files with 88 additions and 4 deletions
@@ -6,11 +6,14 @@ package com.correx.core.kernel.orchestration
import com.correx.core.context.model.ContextEntry import com.correx.core.context.model.ContextEntry
import com.correx.core.context.model.ContextLayer import com.correx.core.context.model.ContextLayer
import com.correx.core.context.model.EntryRole import com.correx.core.context.model.EntryRole
import com.correx.core.events.events.RefinementIterationEvent
import com.correx.core.events.events.RetryAttemptedEvent import com.correx.core.events.events.RetryAttemptedEvent
import com.correx.core.events.events.StoredEvent import com.correx.core.events.events.StoredEvent
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.ContextEntryId import com.correx.core.events.types.ContextEntryId
import com.correx.core.events.types.StageId import com.correx.core.events.types.StageId
import com.correx.core.sessions.BoundProjectProfile import com.correx.core.sessions.BoundProjectProfile
import com.correx.core.transitions.graph.WorkflowGraph
import java.util.UUID import java.util.UUID
fun buildRetryFeedbackEntry(events: List<StoredEvent>, stageId: StageId): ContextEntry? { fun buildRetryFeedbackEntry(events: List<StoredEvent>, stageId: StageId): ContextEntry? {
@@ -32,6 +35,18 @@ fun buildRetryFeedbackEntry(events: List<StoredEvent>, stageId: StageId): Contex
) )
} }
fun criticArtifactIds(
events: List<StoredEvent>,
graph: WorkflowGraph,
stageId: StageId,
): Set<ArtifactId> {
val cycle = events
.mapNotNull { it.payload as? RefinementIterationEvent }
.lastOrNull { it.cycleKey.endsWith("->${stageId.value}") } ?: return emptySet()
val fromStage = StageId(cycle.cycleKey.substringBefore("->"))
return graph.stages[fromStage]?.produces?.map { it.name }?.toSet() ?: emptySet()
}
fun buildProjectProfileEntry(profile: BoundProjectProfile): ContextEntry { fun buildProjectProfileEntry(profile: BoundProjectProfile): ContextEntry {
val content = buildString { val content = buildString {
append("## Project profile\n") append("## Project profile\n")
@@ -23,6 +23,7 @@ import com.correx.core.context.model.ContextPack
import com.correx.core.context.model.TokenBudget import com.correx.core.context.model.TokenBudget
import com.correx.core.context.model.EntryRole import com.correx.core.context.model.EntryRole
import com.correx.core.events.events.ApprovalDecisionResolvedEvent import com.correx.core.events.events.ApprovalDecisionResolvedEvent
import com.correx.core.events.events.RefinementIterationEvent
import com.correx.core.events.events.ApprovalRequestedEvent import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ArtifactContentStoredEvent import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.ArtifactCreatedEvent import com.correx.core.events.events.ArtifactCreatedEvent
@@ -347,7 +348,13 @@ abstract class SessionOrchestrator(
), ),
) )
val repoMapEntries = buildRepoMapEntries(sessionId) val repoMapEntries = buildRepoMapEntries(sessionId)
val needsEntries = buildNeedsArtifactEntries(sessionId, stageConfig) val sessionEvents = eventStore.read(sessionId)
val criticIds = criticArtifactIds(sessionEvents, graph, stageId)
val criticFrom = sessionEvents
.mapNotNull { it.payload as? RefinementIterationEvent }
.lastOrNull { it.cycleKey.endsWith("->${stageId.value}") }
?.cycleKey?.substringBefore("->")
val needsEntries = buildNeedsArtifactEntries(sessionId, stageConfig, criticIds, criticFrom)
val profileEntries = session.state.boundProfile?.about val profileEntries = session.state.boundProfile?.about
?.takeIf { it.isNotBlank() } ?.takeIf { it.isNotBlank() }
?.let { about -> ?.let { about ->
@@ -365,7 +372,7 @@ abstract class SessionOrchestrator(
} ?: emptyList() } ?: emptyList()
val projectProfileEntries = session.state.boundProjectProfile val projectProfileEntries = session.state.boundProjectProfile
?.let { listOf(buildProjectProfileEntry(it)) } ?: emptyList() ?.let { listOf(buildProjectProfileEntry(it)) } ?: emptyList()
val retryFeedbackEntries = buildRetryFeedbackEntry(eventStore.read(sessionId), stageId) val retryFeedbackEntries = buildRetryFeedbackEntry(sessionEvents, stageId)
?.let { listOf(it) } ?: emptyList() ?.let { listOf(it) } ?: emptyList()
var accumulatedEntries = var accumulatedEntries =
systemPrompt + profileEntries + projectProfileEntries + journalEntries + repoMapEntries + systemPrompt + profileEntries + projectProfileEntries + journalEntries + repoMapEntries +
@@ -1015,16 +1022,24 @@ abstract class SessionOrchestrator(
private suspend fun buildNeedsArtifactEntries( private suspend fun buildNeedsArtifactEntries(
sessionId: SessionId, sessionId: SessionId,
stageConfig: StageConfig, stageConfig: StageConfig,
criticIds: Set<ArtifactId> = emptySet(),
criticFromStage: String? = null,
): List<ContextEntry> { ): List<ContextEntry> {
return stageConfig.needs.mapNotNull { needed -> return stageConfig.needs.mapNotNull { needed ->
val cached = artifactContentCache["${sessionId.value}:${needed.value}"] val cached = artifactContentCache["${sessionId.value}:${needed.value}"]
?.takeIf { it.isNotBlank() } ?: return@mapNotNull null ?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
val content = "## Input artifact: ${needed.value}\n$cached" val content = if (needed in criticIds) {
"## Critic feedback from stage '${criticFromStage ?: "reviewer"}': ${needed.value}\n" +
"This is reviewer feedback on your previous attempt. " +
"Address every finding before completing the stage.\n$cached"
} else {
"## Input artifact: ${needed.value}\n$cached"
}
ContextEntry( ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()), id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L1, layer = ContextLayer.L1,
content = content, content = content,
sourceType = "neededArtifact", sourceType = if (needed in criticIds) "criticFeedback" else "neededArtifact",
sourceId = needed.value, sourceId = needed.value,
tokenEstimate = estimateTokens(content), tokenEstimate = estimateTokens(content),
role = EntryRole.USER, role = EntryRole.USER,
@@ -1,11 +1,20 @@
import com.correx.core.artifacts.kind.FileWrittenKind
import com.correx.core.artifacts.kind.TypedArtifactSlot
import com.correx.core.context.model.ContextLayer import com.correx.core.context.model.ContextLayer
import com.correx.core.context.model.EntryRole import com.correx.core.context.model.EntryRole
import com.correx.core.events.events.RefinementIterationEvent
import com.correx.core.events.events.RetryAttemptedEvent import com.correx.core.events.events.RetryAttemptedEvent
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.SessionId import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId import com.correx.core.events.types.StageId
import com.correx.core.events.types.TransitionId
import com.correx.core.kernel.orchestration.buildProjectProfileEntry import com.correx.core.kernel.orchestration.buildProjectProfileEntry
import com.correx.core.kernel.orchestration.buildRetryFeedbackEntry import com.correx.core.kernel.orchestration.buildRetryFeedbackEntry
import com.correx.core.kernel.orchestration.criticArtifactIds
import com.correx.core.sessions.BoundProjectProfile import com.correx.core.sessions.BoundProjectProfile
import com.correx.core.transitions.graph.StageConfig
import com.correx.core.transitions.graph.TransitionEdge
import com.correx.core.transitions.graph.WorkflowGraph
import com.correx.testing.fixtures.EventFixtures.stored import com.correx.testing.fixtures.EventFixtures.stored
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertNull
@@ -72,4 +81,49 @@ class ContextFeedbackTest {
assertTrue(!entry.content.contains("### Commands"), "should not contain Commands: ${entry.content}") assertTrue(!entry.content.contains("### Commands"), "should not contain Commands: ${entry.content}")
assertEquals("projectProfile", entry.sourceType) assertEquals("projectProfile", entry.sourceType)
} }
@Test
fun `criticArtifactIds resolves the from-stage produces on a back-edge`() {
val graph = graphWith(
"implement" to producesSlots("patch"),
"review" to producesSlots("review_report"),
)
val events = listOf(
stored(payload = RefinementIterationEvent(SessionId("s"), "review->implement", 1, 3)),
)
assertEquals(setOf(ArtifactId("review_report")), criticArtifactIds(events, graph, StageId("implement")))
}
@Test
fun `no refinement events yields empty set`() {
assertTrue(criticArtifactIds(emptyList(), graphWith(), StageId("implement")).isEmpty())
}
@Test
fun `criticArtifactIds ignores events targeting a different stage`() {
val graph = graphWith(
"implement" to producesSlots("patch"),
"review" to producesSlots("review_report"),
)
val events = listOf(
stored(payload = RefinementIterationEvent(SessionId("s"), "review->other", 1, 3)),
)
assertTrue(criticArtifactIds(events, graph, StageId("implement")).isEmpty())
}
}
private fun producesSlots(vararg names: String): List<TypedArtifactSlot> =
names.map { TypedArtifactSlot(name = ArtifactId(it), kind = FileWrittenKind) }
private fun graphWith(vararg stages: Pair<String, List<TypedArtifactSlot>>): WorkflowGraph {
val stageMap = stages.associate { (id, slots) ->
StageId(id) to StageConfig(produces = slots)
}.toMutableMap()
if (stageMap.isEmpty()) stageMap[StageId("start")] = StageConfig()
return WorkflowGraph(
id = "test-graph",
stages = stageMap,
transitions = emptySet(),
start = stageMap.keys.first(),
)
} }