B1, B14 und B3 beheben: Compaction-Paarung und Token-Semantik

B1 — Die Compaction behielt blind die letzten 6 Nachrichten. Fiel diese Grenze
mitten in eine Tool-Sequenz, entstand eine tool-Antwort ohne zugehoerigen
assistant-tool_call; die API lehnt das mit HTTP 400 ab. FindSafeTailStart
verschiebt die Grenze jetzt rueckwaerts auf eine Blockgrenze.

B14 — Bei Konversationen mit hoechstens 6 Nachrichten enthielt der Tail auch die
system-Nachricht, die anschliessend ein zweites Mal angehaengt wurde. Ergebnis
war ein doppelter System-Prompt und eine duplizierte Konversation — die
Compaction vergroesserte den Kontext, statt ihn zu verkleinern. Der Tail beginnt
nun grundsaetzlich hinter dem System-Prompt; liegt davor nichts Nennenswertes,
wird die Kompaktierung uebersprungen. Gefunden durch den Property-Test.

Nebeneffekt: Zusammengefasst wird nur noch der Teil, der tatsaechlich wegfaellt.
Der Tail bleibt woertlich erhalten und musste bisher doppelt bezahlt werden.

B3 — maxTokens zaehlte kumulativ ueber alle Schritte, wurde aber wie eine
Kontextgrenze konfiguriert. Da jeder Schritt den vollen Kontext erneut sendet,
brach ein Chat mit 20k Kontext nach vier Schritten ab. Aufgeteilt in
maxCumulativeTokens (Kostenbudget, Default 500k) und maxContextTokens
(Kontextgroesse). Alte Konfigurationen werden beim Laden migriert, die
Fehlermeldungen unterscheiden jetzt Schritt- und Kostenlimit.

Alle 31 Tests gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-27 10:15:50 +02:00
co-authored by Claude Opus 4.8
parent 667cecce25
commit eae13771cf
10 changed files with 209 additions and 23 deletions
@@ -95,12 +95,55 @@ public sealed class ContextCompactor
return pruned;
}
/// <summary>
/// Sucht einen Index, an dem der Kontext geteilt werden darf, ohne eine Tool-Sequenz
/// zu zerreißen.
///
/// Eine <c>tool</c>-Nachricht darf nie am Anfang des behaltenen Teils stehen — ihr
/// <c>assistant</c>-Aufruf stünde sonst nicht mehr davor und die API lehnt die
/// gesamte Anfrage mit HTTP 400 ab. Deshalb wandert die Grenze rückwärts bis zum
/// Beginn des Blocks, in den sie fällt.
/// </summary>
private static int FindSafeTailStart(List<ChatMessage> messages, int desiredStart, int minIndex)
{
var i = Math.Clamp(desiredStart, minIndex, messages.Count);
// Rückwärts, bis der Anfang keine verwaiste Tool-Antwort mehr ist.
while (i > minIndex && i < messages.Count && messages[i].Role == "tool")
i--;
return i;
}
private async Task CompactViaLlmAsync(
List<ChatMessage> messages, string model, CancellationToken ct)
{
var systemMsg = messages.FirstOrDefault(m => m.Role == "system");
// Der System-Prompt wird separat behandelt und darf nie Teil des Tails werden,
// sonst landet er doppelt im Kontext.
var firstNonSystem = systemMsg is null ? 0 : messages.IndexOf(systemMsg) + 1;
var tailStart = FindSafeTailStart(
messages,
messages.Count - ProtectedTailMessages,
firstNonSystem);
// Wenn vor dem geschützten Ende nichts Nennenswertes liegt, gibt es nichts
// zusammenzufassen — eine Kompaktierung würde den Kontext sogar vergrößern.
if (tailStart - firstNonSystem < 2)
{
_logger.LogInformation(
"Compaction übersprungen: nur {Count} Nachricht(en) vor dem geschützten Ende",
tailStart - firstNonSystem);
return;
}
// Nur den Teil zusammenfassen, der tatsächlich wegfällt — der Tail bleibt
// ohnehin wörtlich erhalten und müsste sonst doppelt bezahlt werden.
var conversationParts = messages
.Where(m => m.Role != "system")
.Take(tailStart)
.Skip(firstNonSystem)
.Select(FormatMessageForSummary);
var conversationText = string.Join("\n", conversationParts);
@@ -136,10 +179,9 @@ public sealed class ContextCompactor
return;
}
// Nachrichten ersetzen: System-Prompt + Zusammenfassung + geschützte letzte Nachrichten
var tail = messages
.Skip(Math.Max(0, messages.Count - ProtectedTailMessages))
.ToList();
// Nachrichten ersetzen: System-Prompt + Zusammenfassung + geschützte letzte Nachrichten.
// tailStart liegt garantiert auf einer Blockgrenze und hinter dem System-Prompt.
var tail = messages.Skip(tailStart).ToList();
messages.Clear();