diff --git a/core/inference/src/main/kotlin/com/correx/core/inference/PromptRenderer.kt b/core/inference/src/main/kotlin/com/correx/core/inference/PromptRenderer.kt index 09aef2e7..790d581a 100644 --- a/core/inference/src/main/kotlin/com/correx/core/inference/PromptRenderer.kt +++ b/core/inference/src/main/kotlin/com/correx/core/inference/PromptRenderer.kt @@ -21,6 +21,13 @@ data class ChatMessage( ) object PromptRenderer { + // #293: gate retry/recovery repair mandates. Root cause: they used to render as L1/SYSTEM, so + // they folded into the leading system block — far from the assistant/tool transcript and weaker + // than the original stage task. Instead they render as the FINAL user message, right after the + // tool evidence, where a weak local model attends strongest and reads it as the next action. + // Add a sourceType here (and set the entry's role to USER) to route it to that trailing slot. + private val repairMandateSourceTypes = setOf("retryFeedback") + // Tiebreak only: when entries carry no chronological ordinal (all 0 — e.g. router // chat, which assembles its pack directly), fall back to the old layer priority that // renders L1 (the live user turn) last so the template sees a user query at the end. @@ -42,9 +49,16 @@ object PromptRenderer { .sortedWith(compareBy({ it.first.ordinal }, { it.second.ordinal })) .joinToString("\n\n") { it.second.content } .takeIf { it.isNotBlank() } - val conversationMessages = conversationEntries + // #293: pull repair mandates out of the inline flow — they render once, as the last turn. + val (repairPairs, inlinePairs) = conversationEntries + .partition { it.second.sourceType in repairMandateSourceTypes } + val conversationMessages = inlinePairs .sortedWith(compareBy({ it.second.ordinal }, { layerPriority(it.first) })) .map { (_, entry) -> entry.toChatMessage() } + val repairMandate = repairPairs + .sortedBy { it.second.ordinal } + .joinToString("\n\n") { it.second.content } + .takeIf { it.isNotBlank() } // Repetition anchoring: steering directives fold into the leading system message, far // from the final query — weak local models forget them (lost-in-the-middle). Restate // them once as a trailing user turn, where models attend strongest. Template-safe: a @@ -57,6 +71,8 @@ object PromptRenderer { systemContent?.let { add(ChatMessage("system", it)) } addAll(conversationMessages) anchor?.let { add(ChatMessage("user", "Reminder — active steering directive(s):\n$it")) } + // The repair mandate is the final message — the model's next action after the transcript. + repairMandate?.let { add(ChatMessage("user", it)) } } return messages.ifEmpty { listOf(ChatMessage("user", "")) } } 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 a75be251..49e897c8 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 @@ -71,7 +71,9 @@ fun buildRetryFeedbackEntry(events: List, stageId: StageId): Contex sourceType = "retryFeedback", sourceId = stageId.value, tokenEstimate = content.length / 4, - role = EntryRole.SYSTEM, + // #293: USER (not SYSTEM) so PromptRenderer routes it to the trailing repair-mandate slot — + // the final message after the tool transcript — rather than folding it into leading system. + role = EntryRole.USER, ) } diff --git a/testing/deterministic/src/test/kotlin/PromptRendererOrderingTest.kt b/testing/deterministic/src/test/kotlin/PromptRendererOrderingTest.kt index 79b44ba8..4707ef72 100644 --- a/testing/deterministic/src/test/kotlin/PromptRendererOrderingTest.kt +++ b/testing/deterministic/src/test/kotlin/PromptRendererOrderingTest.kt @@ -53,6 +53,30 @@ class PromptRendererOrderingTest { Unit } + @Test + fun `retry repair mandate renders as the final user turn after the tool transcript`() = kotlinx.coroutines.runBlocking { + // #293: even though the repair mandate is stamped mid-transcript by input order, the renderer + // must lift it to the very end (after assistant/tool evidence) and never fold it into system. + val builder = DefaultContextPackBuilder(DefaultContextCompressor()) + val entries = listOf( + entry("sys", ContextLayer.L0, EntryRole.SYSTEM, "systemPrompt"), + entry("task", ContextLayer.L1, EntryRole.USER, "agentPrompt"), + entry("repair", ContextLayer.L1, EntryRole.USER, "retryFeedback"), + entry("call1", ContextLayer.L2, EntryRole.ASSISTANT, "assistantToolCall", sourceId = "tool1"), + entry("result1", ContextLayer.L2, EntryRole.TOOL, "toolResult", sourceId = "tool1"), + ) + val pack = builder.build(ContextPackId("p"), sessionId, stageId, entries, TokenBudget(limit = 4000)) + + val messages = PromptRenderer.render(pack) + assertEquals("user" to "repair", messages.last().role to messages.last().content) + assertEquals(1, messages.count { it.content == "repair" }, "mandate must appear exactly once") + org.junit.jupiter.api.Assertions.assertFalse( + messages.first().content.contains("repair"), + "leading system must not carry the repair mandate", + ) + Unit + } + @Test fun `without ordinals the live user turn still renders last (router chat fallback)`() { // Packs built outside DefaultContextPackBuilder (router chat) leave ordinal at 0; diff --git a/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt b/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt index c7e5baad..c3004758 100644 --- a/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt +++ b/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt @@ -50,7 +50,7 @@ class ContextFeedbackTest { } @Test - fun `latest retry event for the stage becomes a labeled L1 system entry`() { + fun `latest retry event for the stage becomes a labeled L1 user repair mandate`() { val events = listOf( stored(payload = RetryAttemptedEvent(SessionId("s"), StageId("impl"), 1, 3, "old reason")), stored(payload = RetryAttemptedEvent(SessionId("s"), StageId("other"), 1, 3, "other stage")), @@ -59,7 +59,8 @@ class ContextFeedbackTest { val entry = buildRetryFeedbackEntry(events, StageId("impl"))!! assertEquals("retryFeedback", entry.sourceType) assertEquals(ContextLayer.L1, entry.layer) - assertEquals(EntryRole.SYSTEM, entry.role) + // #293: USER role so it renders as the trailing repair mandate, not folded into leading system. + assertEquals(EntryRole.USER, entry.role) assertTrue(entry.content.contains("Attempt 2 of 3"), "content: ${entry.content}") assertTrue(entry.content.contains("tests failed: NPE in FooTest"), "content: ${entry.content}") }