using ClawdDotNet.Core.Api; using ClawdDotNet.Core.Api.Models; using ClawdDotNet.Core.Config; using Microsoft.Extensions.Logging; namespace ClawdDotNet.Core.Engine; public sealed class ContextCompactor { private readonly IChatCompletionClient _client; private readonly ILogger _logger; private const int ProtectedTailMessages = 6; /// /// Wohin kompaktiert wird, als Anteil von - /// nie hoeher als die Schwelle selbst (#605). /// /// Vorher reichte "knapp unter der Schwelle". Im Dauerbetrieb kuerzte Stufe 1 dann /// bei jedem Schritt ein wenig, der Kontext lag sofort wieder darueber, und weil jede /// Kuerzung alte Nachrichten aendert, brach dabei jedes Mal der Prompt-Cache. Mit Abstand /// wird selten und kraeftig kompaktiert, und der Cache haelt danach mehrere Laeufe. /// internal const double ZielAnteil = 0.55; private const int MaxToolResultChars = 2000; private const string TruncatedMarker = "\n\n[... Ergebnis gekürzt ...]"; /// /// Wie stark ein einzelner Messwert den Faktor verschiebt. Klein genug, dass ein /// Ausreißer die Schwelle nicht kippt, groß genug, dass ein Modellwechsel nach /// wenigen Aufrufen ankommt. /// private const double CalibrationWeight = 0.25; /// /// Je Modell: um welchen Faktor die rohe Schätzung danebenlag. 1,0 heißt „noch nichts /// gemessen". Modellweise, weil verschiedene Tokenizer denselben Text verschieden /// zerlegen — ein gemeinsamer Faktor wäre der Mittelwert von etwas, das nichts /// miteinander zu tun hat. /// private readonly System.Collections.Concurrent.ConcurrentDictionary _calibration = new(); public ContextCompactor(IChatCompletionClient client, ILoggerFactory loggerFactory) { _client = client; _logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.ContextCompactor"); } /// /// Die rohe Schätzung: vier Zeichen je Token. Grob, aber ohne Netzaufruf und ohne /// Kenntnis des Tokenizers — und darauf kommt es an, weil sie vor dem Senden /// gebraucht wird. zieht die gemessene Abweichung nach. /// public static int EstimateTokens(List messages) { var totalChars = 0; foreach (var msg in messages) { totalChars += msg.Content?.Length ?? 0; totalChars += msg.Role.Length + 10; if (msg.ToolCalls is not null) { foreach (var tc in msg.ToolCalls) totalChars += tc.Function.Name.Length + tc.Function.Arguments.Length + 20; } } return totalChars / 4; } /// /// Die Schätzung für ein bestimmtes Modell — die rohe Rechnung, korrigiert um das, /// was bei diesem Modell bisher tatsächlich gemessen wurde. /// public int EstimateFor(string model, List messages) => (int)(EstimateTokens(messages) * _calibration.GetValueOrDefault(model, 1.0)); /// /// Meldet, wie groß der Prompt wirklich war, den eine Schätzung vorhergesagt hat. /// /// Vier Zeichen je Token stimmt für englische Fließtexte ungefähr und liegt /// bei deutschem Text, JSON und Code daneben — je nach Modell in beide Richtungen. /// Der Faktor zieht die Schätzung dorthin, wo dieses Modell tatsächlich liegt. /// /// Gleitender Mittelwert statt letztem Messwert: Ein einzelner Ausreißer soll /// die Schwelle nicht verschieben. Begrenzt auf das Halbe bis Vierfache — was /// darüber hinausginge, ist kein Kalibrierungsfall mehr, sondern ein Fehler /// woanders, und den soll die Schätzung nicht kaschieren. /// public void Calibrate(string model, int estimatedTokens, int actualPromptTokens) { if (estimatedTokens <= 0 || actualPromptTokens <= 0) return; var gemessen = Math.Clamp((double)actualPromptTokens / estimatedTokens, 0.5, 4.0); // Auch der erste Messwert zählt nur mit seinem Gewicht — Ausgangspunkt ist die // rohe Schätzung (Faktor 1,0). Setzte der erste Wert den Faktor voll, könnte ein // einzelner Ausreißer die Schwelle sofort verschieben, und genau das soll der // gleitende Mittelwert verhindern. static double Naechster(double bisher, double gemessen) => bisher * (1 - CalibrationWeight) + gemessen * CalibrationWeight; _calibration.AddOrUpdate( model, Naechster(1.0, gemessen), (_, bisher) => Naechster(bisher, gemessen)); } /// /// Erhaelt den Verbrauch des Zusammenfassungs-Aufrufs (#605). Die Engine zaehlt ihn zum /// Lauf - sonst waere er in Verbrauch, Beleg und Tagesbudget unsichtbar. /// public async Task CompactIfNeededAsync( List messages, LoopGuardConfig guard, string model, CancellationToken ct, Action? verbrauch = null) { var contextTokens = EstimateFor(model, messages); var threshold = (int)(guard.MaxContextTokens * guard.CompactionThreshold); if (contextTokens < threshold) return false; var ziel = Math.Min(threshold, (int)(guard.MaxContextTokens * ZielAnteil)); _logger.LogInformation( "Context kompaktierung gestartet: {Tokens} Tokens (Schwelle: {Threshold}, Ziel: {Ziel})", contextTokens, threshold, ziel); // Stufe 1: Tool-Results kürzen var pruned = PruneToolResults(messages); if (pruned) { // #605: Mit derselben, kalibrierten Schaetzung pruefen, die ausgeloest hat. Die // rohe Zahl (Zeichen/4) lag bei deutschem Text und JSON deutlich zu niedrig, // meldete "unter der Schwelle", und Stufe 2 lief nie. var afterPrune = EstimateFor(model, messages); _logger.LogInformation("Stufe 1 (Tool-Pruning): {Before} → {After} geschätzte Tokens (Ziel: {Ziel})", contextTokens, afterPrune, ziel); if (afterPrune < ziel) return true; } // Stufe 2: Auto-Compaction via LLM — bewusst mit dem günstigen Modell. var summaryModel = string.IsNullOrWhiteSpace(guard.SummaryModel) ? model : guard.SummaryModel; await CompactViaLlmAsync(messages, summaryModel, ct, verbrauch); var danach = EstimateFor(model, messages); if (danach >= threshold) { _logger.LogWarning( "Kompaktierung bleibt über der Schwelle: {After} geschätzte Tokens (Schwelle: {Threshold}). " + "Das geschützte Ende der letzten {Tail} Nachrichten ist allein zu groß.", danach, threshold, ProtectedTailMessages); } else { _logger.LogInformation("Kompaktierung abgeschlossen: {Before} → {After} geschätzte Tokens (Ziel: {Ziel})", contextTokens, danach, ziel); } return true; } private bool PruneToolResults(List messages) { var pruned = false; var protectedStart = Math.Max(0, messages.Count - ProtectedTailMessages); for (var i = 0; i < protectedStart; i++) { var msg = messages[i]; if (msg.Role != "tool" || msg.Content is null) continue; if (msg.Content.Length <= MaxToolResultChars) continue; msg.Content = msg.Content[..MaxToolResultChars] + TruncatedMarker; pruned = true; } return pruned; } /// /// Sucht einen Index, an dem der Kontext geteilt werden darf, ohne eine Tool-Sequenz /// zu zerreißen. /// /// Eine tool-Nachricht darf nie am Anfang des behaltenen Teils stehen — ihr /// assistant-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. /// private static int FindSafeTailStart(List 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 messages, string model, CancellationToken ct, Action? verbrauch) { 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 .Take(tailStart) .Skip(firstNonSystem) .Select(FormatMessageForSummary); var conversationText = string.Join("\n", conversationParts); // Auf max 30k Zeichen begrenzen für den Summarization-Call if (conversationText.Length > 30_000) conversationText = conversationText[..30_000] + "\n[... weitere Nachrichten ausgelassen ...]"; var summaryRequest = new ChatRequest { Model = model, Messages = [ ChatMessage.System( "Du bist ein Konversations-Zusammenfasser. Erstelle eine präzise Zusammenfassung " + "der bisherigen Konversation. Behalte alle wichtigen Fakten, Entscheidungen, " + "Ergebnisse von Tool-Aufrufen und den aktuellen Arbeitsstand bei. " + "Schreibe in der dritten Person. Format: Strukturierte Stichpunkte."), ChatMessage.User( "Fasse die folgende Konversation zusammen. Behalte alle wichtigen Details:\n\n" + conversationText) ] }; try { var response = await _client.CompleteAsync(summaryRequest, ct); // Bezahlt ist der Aufruf auch dann, wenn die Zusammenfassung unbrauchbar ist. if (response.Usage is { } usage) verbrauch?.Invoke(usage); var summary = response.Choices.FirstOrDefault()?.Message?.Content; if (string.IsNullOrWhiteSpace(summary)) { _logger.LogWarning("Compaction: Keine Zusammenfassung erhalten"); return; } // 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(); if (systemMsg is not null) messages.Add(systemMsg); messages.Add(ChatMessage.User( "[Zusammenfassung der bisherigen Konversation]\n\n" + summary)); messages.Add(ChatMessage.Assistant( "Verstanden. Ich habe den Kontext der bisherigen Konversation erfasst und arbeite weiter.")); messages.AddRange(tail); _logger.LogInformation( "Stufe 2 (Auto-Compaction): Konversation auf {Count} Nachrichten kompaktiert", messages.Count); } catch (Exception ex) { _logger.LogError(ex, "Compaction fehlgeschlagen"); } } private static string FormatMessageForSummary(ChatMessage msg) { if (msg.Role == "tool") { var preview = msg.Content?.Length > 200 ? msg.Content[..200] + "..." : msg.Content; return $"[Tool-Result ({msg.ToolCallId})]: {preview}"; } if (msg.ToolCalls is { Count: > 0 }) { var calls = string.Join(", ", msg.ToolCalls.Select(tc => $"{tc.Function.Name}({tc.Function.Arguments[..Math.Min(100, tc.Function.Arguments.Length)]})")); return $"[Assistant → Tool-Calls]: {calls}"; } var content = msg.Content?.Length > 500 ? msg.Content[..500] + "..." : msg.Content; return $"[{msg.Role}]: {content}"; } }