diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt index 441a4da4..fa95f9a0 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt @@ -6,11 +6,14 @@ package com.correx.core.kernel.orchestration import com.correx.core.context.model.ContextEntry import com.correx.core.context.model.ContextLayer 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.StoredEvent +import com.correx.core.events.types.ArtifactId import com.correx.core.events.types.ContextEntryId import com.correx.core.events.types.StageId import com.correx.core.sessions.BoundProjectProfile +import com.correx.core.transitions.graph.WorkflowGraph import java.util.UUID fun buildRetryFeedbackEntry(events: List, stageId: StageId): ContextEntry? { @@ -32,6 +35,18 @@ fun buildRetryFeedbackEntry(events: List, stageId: StageId): Contex ) } +fun criticArtifactIds( + events: List, + graph: WorkflowGraph, + stageId: StageId, +): Set { + 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 { val content = buildString { append("## Project profile\n") diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index e7d0e93f..ae4f0fb5 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -23,6 +23,7 @@ import com.correx.core.context.model.ContextPack import com.correx.core.context.model.TokenBudget import com.correx.core.context.model.EntryRole 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.ArtifactContentStoredEvent import com.correx.core.events.events.ArtifactCreatedEvent @@ -347,7 +348,13 @@ abstract class SessionOrchestrator( ), ) 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 ?.takeIf { it.isNotBlank() } ?.let { about -> @@ -365,7 +372,7 @@ abstract class SessionOrchestrator( } ?: emptyList() val projectProfileEntries = session.state.boundProjectProfile ?.let { listOf(buildProjectProfileEntry(it)) } ?: emptyList() - val retryFeedbackEntries = buildRetryFeedbackEntry(eventStore.read(sessionId), stageId) + val retryFeedbackEntries = buildRetryFeedbackEntry(sessionEvents, stageId) ?.let { listOf(it) } ?: emptyList() var accumulatedEntries = systemPrompt + profileEntries + projectProfileEntries + journalEntries + repoMapEntries + @@ -1015,16 +1022,24 @@ abstract class SessionOrchestrator( private suspend fun buildNeedsArtifactEntries( sessionId: SessionId, stageConfig: StageConfig, + criticIds: Set = emptySet(), + criticFromStage: String? = null, ): List { return stageConfig.needs.mapNotNull { needed -> val cached = artifactContentCache["${sessionId.value}:${needed.value}"] ?.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( id = ContextEntryId(UUID.randomUUID().toString()), layer = ContextLayer.L1, content = content, - sourceType = "neededArtifact", + sourceType = if (needed in criticIds) "criticFeedback" else "neededArtifact", sourceId = needed.value, tokenEstimate = estimateTokens(content), role = EntryRole.USER, diff --git a/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt b/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt index ca90a89d..3762ce0d 100644 --- a/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt +++ b/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt @@ -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.EntryRole +import com.correx.core.events.events.RefinementIterationEvent 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.StageId +import com.correx.core.events.types.TransitionId import com.correx.core.kernel.orchestration.buildProjectProfileEntry import com.correx.core.kernel.orchestration.buildRetryFeedbackEntry +import com.correx.core.kernel.orchestration.criticArtifactIds 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 org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull @@ -72,4 +81,49 @@ class ContextFeedbackTest { assertTrue(!entry.content.contains("### Commands"), "should not contain Commands: ${entry.content}") 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 = + names.map { TypedArtifactSlot(name = ArtifactId(it), kind = FileWrittenKind) } + +private fun graphWith(vararg stages: Pair>): 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(), + ) }