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.
This commit is contained in:
2026-06-11 21:55:28 +04:00
parent 8bf56604c7
commit 07819ec82d
2 changed files with 35 additions and 9 deletions
@@ -23,15 +23,18 @@ object PromptRenderer {
} }
fun render(contextPack: ContextPack): List<ChatMessage> { fun render(contextPack: ContextPack): List<ChatMessage> {
val sorted = contextPack.layers.entries.sortedBy { it.key.ordinal } // Every SYSTEM-role entry folds into the single leading system message, whatever its
val systemContent = sorted // layer (L0 additionally folds regardless of role). Strict chat templates (e.g. Qwen)
.filter { it.key == ContextLayer.L0 } // reject any system message that is not the first message, so recalled memory, stage
.flatMap { it.value } // summaries, and retrieval entries must never render as standalone system turns.
.joinToString("\n\n") { it.content } val (systemEntries, conversationEntries) = contextPack.layers.entries
.takeIf { it.isNotBlank() }
val conversationMessages = contextPack.layers.entries
.filter { it.key != ContextLayer.L0 }
.flatMap { (layer, entries) -> entries.map { layer to it } } .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) })) .sortedWith(compareBy({ it.second.ordinal }, { layerPriority(it.first) }))
.map { (_, entry) -> entry.toChatMessage() } .map { (_, entry) -> entry.toChatMessage() }
val messages = buildList { val messages = buildList {
@@ -67,6 +67,29 @@ class PromptRendererOrderingTest {
budgetLimit = 4000, budgetLimit = 4000,
) )
val messages = PromptRenderer.render(pack) 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 },
)
} }
} }