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:
@@ -94,4 +94,23 @@ class DefaultContextPackBuilderTest {
|
||||
assertTrue(allRetained.any { it.id == ContextEntryId("steering") }, "steeringNote must be retained")
|
||||
assertTrue(allRetained.any { it.id == ContextEntryId("history") }, "eventHistory must be retained")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tool dialogue keeps chronological order despite sourceType grouping`() {
|
||||
// A multi-round tool loop: assistant call then tool result, interleaved. Compression
|
||||
// groups by sourceType internally; the builder must restore input order so the L2
|
||||
// layer reads call, result, call, result — not all calls then all results.
|
||||
val entries = listOf(
|
||||
entry("task", ContextLayer.L1, 10).copy(sourceType = "agentPrompt"),
|
||||
entry("call1", ContextLayer.L2, 10).copy(sourceType = "assistantToolCall"),
|
||||
entry("result1", ContextLayer.L2, 10).copy(sourceType = "toolResult"),
|
||||
entry("call2", ContextLayer.L2, 10).copy(sourceType = "assistantToolCall"),
|
||||
entry("result2", ContextLayer.L2, 10).copy(sourceType = "toolResult"),
|
||||
)
|
||||
val pack = builder.build(packId, sessionId, stageId, entries, TokenBudget(limit = 4000))
|
||||
val l2Ids = pack.layers[ContextLayer.L2]!!.map { it.id.value }
|
||||
assertEquals(listOf("call1", "result1", "call2", "result2"), l2Ids)
|
||||
// ordinals are stamped from input order
|
||||
assertEquals(listOf(1, 2, 3, 4), pack.layers[ContextLayer.L2]!!.map { it.ordinal })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import com.correx.core.context.builder.DefaultContextPackBuilder
|
||||
import com.correx.core.context.compression.DefaultContextCompressor
|
||||
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
|
||||
import com.correx.core.context.model.TokenBudget
|
||||
import com.correx.core.events.types.ContextEntryId
|
||||
import com.correx.core.events.types.ContextPackId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.inference.PromptRenderer
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class PromptRendererOrderingTest {
|
||||
|
||||
private val sessionId = SessionId("s1")
|
||||
private val stageId = StageId("stage-1")
|
||||
|
||||
private fun entry(id: String, layer: ContextLayer, role: EntryRole, sourceType: String) = ContextEntry(
|
||||
id = ContextEntryId(id),
|
||||
layer = layer,
|
||||
content = id,
|
||||
sourceType = sourceType,
|
||||
sourceId = id,
|
||||
tokenEstimate = 10,
|
||||
role = role,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `stage tool loop renders chronologically with the task before tool turns`() {
|
||||
// The builder stamps ordinals from input order; the renderer must honour them so the
|
||||
// model sees: system, task, assistant call, tool result, assistant call, tool result.
|
||||
val builder = DefaultContextPackBuilder(DefaultContextCompressor())
|
||||
val entries = listOf(
|
||||
entry("sys", ContextLayer.L0, EntryRole.SYSTEM, "systemPrompt"),
|
||||
entry("task", ContextLayer.L1, EntryRole.USER, "agentPrompt"),
|
||||
entry("call1", ContextLayer.L2, EntryRole.ASSISTANT, "assistantToolCall"),
|
||||
entry("result1", ContextLayer.L2, EntryRole.TOOL, "toolResult"),
|
||||
entry("call2", ContextLayer.L2, EntryRole.ASSISTANT, "assistantToolCall"),
|
||||
entry("result2", ContextLayer.L2, EntryRole.TOOL, "toolResult"),
|
||||
)
|
||||
val pack = builder.build(ContextPackId("p"), sessionId, stageId, entries, TokenBudget(limit = 4000))
|
||||
|
||||
val messages = PromptRenderer.render(pack)
|
||||
assertEquals(
|
||||
listOf("system" to "sys", "user" to "task", "assistant" to "call1",
|
||||
"tool" to "result1", "assistant" to "call2", "tool" to "result2"),
|
||||
messages.map { it.role to it.content },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `without ordinals the live user turn still renders last (router chat fallback)`() {
|
||||
// Packs built outside DefaultContextPackBuilder (router chat) leave ordinal at 0;
|
||||
// the renderer falls back to layer priority so L1 (the user query) lands last.
|
||||
val pack = ContextPack(
|
||||
id = ContextPackId("p"),
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
layers = mapOf(
|
||||
ContextLayer.L1 to listOf(entry("question", ContextLayer.L1, EntryRole.USER, "chat")),
|
||||
ContextLayer.L2 to listOf(entry("memory", ContextLayer.L2, EntryRole.SYSTEM, "l2")),
|
||||
),
|
||||
budgetUsed = 20,
|
||||
budgetLimit = 4000,
|
||||
)
|
||||
val messages = PromptRenderer.render(pack)
|
||||
assertEquals(listOf("memory", "question"), messages.map { it.content })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user