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
@@ -23,7 +23,12 @@ class DefaultContextPackBuilder(
entries: List<ContextEntry>,
budget: TokenBudget
): ContextPack {
val (pinned, compressible) = entries.partition { it.sourceType in neverDropSourceTypes }
// Stamp chronological order from the caller's input sequence. Grouping by
// sourceType (for compression) and by layer reorders entries; the ordinal lets
// us restore true turn order afterwards so a tool loop reads assistant→tool→…
// instead of all-assistants-then-all-tools.
val ordered = entries.mapIndexed { index, entry -> entry.copy(ordinal = index) }
val (pinned, compressible) = ordered.partition { it.sourceType in neverDropSourceTypes }
val pinnedTokens = pinned.sumOf { it.tokenEstimate }
var remainingTokens = (budget.limit - pinnedTokens).coerceAtLeast(0)
@@ -41,7 +46,9 @@ class DefaultContextPackBuilder(
result
}
val retained = pinned + compressed
// Restore chronological order before layering — groupBy preserves encounter
// order, so each layer's list comes out in true turn order.
val retained = (pinned + compressed).sortedBy { it.ordinal }
val layers = retained.groupBy { it.layer }
val budgetUsed = retained.sumOf { it.tokenEstimate }
val droppedCount = entries.size - retained.size
@@ -14,4 +14,10 @@ data class ContextEntry(
val sourceId: String,
val tokenEstimate: Int,
val role: EntryRole = EntryRole.USER,
// Monotonic chronological position within a single context assembly. Stamped by
// DefaultContextPackBuilder from input order so that grouping/compression (which
// reorder by sourceType/layer) and PromptRenderer can recover true turn order.
// Entries built outside that builder (e.g. router chat) leave this at 0 and fall
// back to layer-priority ordering in PromptRenderer.
val ordinal: Int = 0,
)