fix(context): preserve chronological turn order through pack assembly

Tool-calling loops were rendered as all-assistant-calls-then-all-tool-results
with the original task last, causing the model to re-issue the same tool call
indefinitely. Two stacked reorderings caused it:

- DefaultContextPackBuilder grouped entries by sourceType for per-type
  compression, discarding a,t,a,t interleaving.
- PromptRenderer forced L1 (the live user turn) to render last, pushing the
  task after the entire transcript.
- SessionOrchestrator's tool loop re-fed currentContext.layers.values.flatten()
  (grouped by layer) each round, compounding the scramble.

Add a chronological `ordinal` to ContextEntry, stamped by the builder from
input order and restored after grouping/compression (the compressor preserves
entry identity, so ordinals survive). PromptRenderer now orders non-system
messages by ordinal, with the old L1-last layer priority kept only as a
tiebreak so router chat (ordinal 0) is unchanged. The orchestrator keeps a
running ordered accumulator instead of reading back the grouped pack.

Adds builder + renderer ordering regression tests.
This commit is contained in:
2026-06-03 22:22:52 +04:00
parent 2bef4b96a5
commit 8fe3a504bb
6 changed files with 135 additions and 25 deletions
@@ -1,5 +1,6 @@
package com.correx.core.inference
import com.correx.core.context.model.ContextEntry
import com.correx.core.context.model.ContextLayer
import com.correx.core.context.model.ContextPack
import com.correx.core.context.model.EntryRole
@@ -12,12 +13,13 @@ data class ChatMessage(
)
object PromptRenderer {
// L1 (live conversation, ends with the current user turn) must render last so
// the model template sees the user query as the final message. Background/memory
// layers (L2, L3, L4) are injected before it.
private val nonL0RenderOrder: Comparator<ContextLayer> = Comparator { a, b ->
val priority = { layer: ContextLayer -> if (layer == ContextLayer.L1) Int.MAX_VALUE else layer.ordinal }
priority(a) - priority(b)
// 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.
// When ordinals are present (orchestrator stage path), they dominate and produce true
// chronological order — a tool loop reads task → assistant → tool → assistant → …
private val layerPriority: (ContextLayer) -> Int = { layer ->
if (layer == ContextLayer.L1) Int.MAX_VALUE else layer.ordinal
}
fun render(contextPack: ContextPack): List<ChatMessage> {
@@ -29,24 +31,23 @@ object PromptRenderer {
.takeIf { it.isNotBlank() }
val conversationMessages = contextPack.layers.entries
.filter { it.key != ContextLayer.L0 }
.sortedWith(Comparator.comparing({ it.key }, nonL0RenderOrder))
.flatMap { (_, entries) ->
entries.map { entry ->
ChatMessage(
role = when (entry.role) {
EntryRole.SYSTEM -> "system"
EntryRole.ASSISTANT -> "assistant"
EntryRole.TOOL -> "tool"
EntryRole.USER -> "user"
},
content = entry.content,
)
}
}
.flatMap { (layer, entries) -> entries.map { layer to it } }
.sortedWith(compareBy({ it.second.ordinal }, { layerPriority(it.first) }))
.map { (_, entry) -> entry.toChatMessage() }
val messages = buildList {
systemContent?.let { add(ChatMessage("system", it)) }
addAll(conversationMessages)
}
return messages.ifEmpty { listOf(ChatMessage("user", "")) }
}
private fun ContextEntry.toChatMessage(): ChatMessage = ChatMessage(
role = when (role) {
EntryRole.SYSTEM -> "system"
EntryRole.ASSISTANT -> "assistant"
EntryRole.TOOL -> "tool"
EntryRole.USER -> "user"
},
content = content,
)
}