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; private const int MaxToolResultChars = 2000; private const string TruncatedMarker = "\n\n[... Ergebnis gekürzt ...]"; public ContextCompactor(IChatCompletionClient client, ILoggerFactory loggerFactory) { _client = client; _logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.ContextCompactor"); } 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; } public async Task CompactIfNeededAsync( List messages, int lastPromptTokens, LoopGuardConfig guard, string model, CancellationToken ct) { var contextTokens = lastPromptTokens > 0 ? lastPromptTokens : EstimateTokens(messages); var threshold = (int)(guard.MaxContextTokens * guard.CompactionThreshold); if (contextTokens < threshold) return false; _logger.LogInformation( "Context kompaktierung gestartet: {Tokens} Tokens (Schwelle: {Threshold})", contextTokens, threshold); // Stufe 1: Tool-Results kürzen var pruned = PruneToolResults(messages); if (pruned) { var afterPrune = EstimateTokens(messages); _logger.LogInformation("Stufe 1 (Tool-Pruning): {Before} → {After} geschätzte Tokens", contextTokens, afterPrune); if (afterPrune < threshold) 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); 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) { 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); 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}"; } }