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:
+9
-2
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user