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
@@ -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 })
}
}