From 07819ec82d38fea779efbfe008a386c655f005a9 Mon Sep 17 00:00:00 2001 From: kami Date: Thu, 11 Jun 2026 21:55:28 +0400 Subject: [PATCH] fix(inference): fold non-L0 SYSTEM entries into the leading system message Strict chat templates (Qwen3.5) reject any system message after index 0. Recalled L3 memory, L2 stage summaries, and retrieval entries carry EntryRole.SYSTEM and rendered as standalone mid-conversation system turns, making llama-server 400 on every router turn once L3 recall returned hits. --- .../correx/core/inference/PromptRenderer.kt | 19 ++++++++------ .../test/kotlin/PromptRendererOrderingTest.kt | 25 ++++++++++++++++++- 2 files changed, 35 insertions(+), 9 deletions(-) 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 }, + ) } }