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 a52d821a..6f4bf2ac 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 @@ -23,15 +23,18 @@ object PromptRenderer { } fun render(contextPack: ContextPack): List { - val sorted = contextPack.layers.entries.sortedBy { it.key.ordinal } - val systemContent = sorted - .filter { it.key == ContextLayer.L0 } - .flatMap { it.value } - .joinToString("\n\n") { it.content } - .takeIf { it.isNotBlank() } - val conversationMessages = contextPack.layers.entries - .filter { it.key != ContextLayer.L0 } + // Every SYSTEM-role entry folds into the single leading system message, whatever its + // layer (L0 additionally folds regardless of role). Strict chat templates (e.g. Qwen) + // reject any system message that is not the first message, so recalled memory, stage + // summaries, and retrieval entries must never render as standalone system turns. + val (systemEntries, conversationEntries) = contextPack.layers.entries .flatMap { (layer, entries) -> entries.map { layer to it } } + .partition { (layer, entry) -> layer == ContextLayer.L0 || entry.role == EntryRole.SYSTEM } + val systemContent = systemEntries + .sortedWith(compareBy({ it.first.ordinal }, { it.second.ordinal })) + .joinToString("\n\n") { it.second.content } + .takeIf { it.isNotBlank() } + val conversationMessages = conversationEntries .sortedWith(compareBy({ it.second.ordinal }, { layerPriority(it.first) })) .map { (_, entry) -> entry.toChatMessage() } val messages = buildList { diff --git a/testing/deterministic/src/test/kotlin/PromptRendererOrderingTest.kt b/testing/deterministic/src/test/kotlin/PromptRendererOrderingTest.kt index 7e6c6553..45058fb9 100644 --- a/testing/deterministic/src/test/kotlin/PromptRendererOrderingTest.kt +++ b/testing/deterministic/src/test/kotlin/PromptRendererOrderingTest.kt @@ -67,6 +67,29 @@ class PromptRendererOrderingTest { budgetLimit = 4000, ) val messages = PromptRenderer.render(pack) - assertEquals(listOf("memory", "question"), messages.map { it.content }) + assertEquals(listOf("system" to "memory", "user" to "question"), messages.map { it.role to it.content }) + } + + @Test + fun `non-L0 SYSTEM entries fold into the single leading system message`() { + // Strict chat templates (Qwen) 400 on any system message after index 0. Recalled L3 + // memory and L2 summaries carry EntryRole.SYSTEM and must merge into the system block. + val pack = ContextPack( + id = ContextPackId("p"), + sessionId = sessionId, + stageId = stageId, + layers = mapOf( + ContextLayer.L0 to listOf(entry("sys", ContextLayer.L0, EntryRole.SYSTEM, "systemPrompt")), + ContextLayer.L1 to listOf(entry("question", ContextLayer.L1, EntryRole.USER, "chat")), + ContextLayer.L3 to listOf(entry("recalled", ContextLayer.L3, EntryRole.SYSTEM, "recalledMemory")), + ), + budgetUsed = 30, + budgetLimit = 4000, + ) + val messages = PromptRenderer.render(pack) + assertEquals( + listOf("system" to "sys\n\nrecalled", "user" to "question"), + messages.map { it.role to it.content }, + ) } }