using ClawdDotNet.Core.Api.Models; using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Engine; using ClawdDotNet.Core.Tests.Infrastructure; using Shouldly; namespace ClawdDotNet.Core.Tests.Engine; /// /// #605: Im Dauerbetrieb lief die Kompaktierung bei jedem Schritt an und blieb wirkungslos - /// Stufe 1 pruefte mit der rohen statt der kalibrierten Schaetzung, kompaktierte nur knapp /// unter die Schwelle, und der Zusammenfassungs-Aufruf wurde nicht verbucht. /// public sealed class KompaktierungAbstandTests { private const string Model = "test/model"; private static (ContextCompactor Compactor, FakeChatClient Client) Erzeuge() { var client = new FakeChatClient().AlwaysRespondsWithText("- Der Agent hat Daten geprueft."); return (new ContextCompactor(client, TestLogging.Factory), client); } /// /// Die rohe Schaetzung, wie sie nach Stufe 1 aussaehe - nachgebaut, um Schwellen zu /// waehlen, an denen alte und neue Pruefung verschieden entscheiden. Werte wie im /// ContextCompactor: die letzten 6 Nachrichten geschuetzt, Tool-Ergebnisse auf 2.000 Zeichen. /// private static int RohNachStufe1(List messages) { var kopie = messages .Select(m => new ChatMessage { Role = m.Role, Content = m.Content, ToolCallId = m.ToolCallId, ToolCalls = m.ToolCalls }) .ToList(); var schutz = Math.Max(0, kopie.Count - 6); for (var i = 0; i < schutz; i++) { if (kopie[i].Role == "tool" && kopie[i].Content is { Length: > 2_000 } inhalt) kopie[i].Content = inhalt[..2_000] + "\n\n[... Ergebnis gekürzt ...]"; } return ContextCompactor.EstimateTokens(kopie); } private static List LangerVerlauf() => Conversation.Start().Repeat(times: 6, toolCallsPerCycle: 1, resultLength: 8_000).Build(); [Fact] public async Task Nach_Stufe_1_entscheidet_die_kalibrierte_Schaetzung() { var (compactor, client) = Erzeuge(); // Dieses Modell zaehlt rund dreimal so viele Tokens, wie Zeichen/4 vermuten laesst. for (var i = 0; i < 12; i++) compactor.Calibrate(Model, 1_000, 3_000); var messages = LangerVerlauf(); var rohDanach = RohNachStufe1(messages); // Roh liegt Stufe 1 unter der Schwelle, kalibriert deutlich darueber. var schwelle = (int)(rohDanach * 1.5); var guard = new LoopGuardConfig { MaxContextTokens = schwelle * 2, CompactionThreshold = 0.5 }; await compactor.CompactIfNeededAsync(messages, guard, Model, default); client.CallCount.ShouldBe(1, "die rohe Zahl meldete faelschlich 'unter der Schwelle' und Stufe 2 lief nie"); ContextInvariants.AssertValid(messages); } [Fact] public async Task Knapp_unter_der_Schwelle_reicht_nicht_es_wird_mit_Abstand_kompaktiert() { var (compactor, client) = Erzeuge(); var messages = LangerVerlauf(); var rohDanach = RohNachStufe1(messages); // Stufe 1 bringt knapp unter die Schwelle (90 %), aber nicht unter das Ziel (55 %). var schwelle = (int)(rohDanach * 1.2); var guard = new LoopGuardConfig { MaxContextTokens = (int)(schwelle / 0.9), CompactionThreshold = 0.9 }; await compactor.CompactIfNeededAsync(messages, guard, Model, default); client.CallCount.ShouldBe(1, "knapp unter der Schwelle waere beim naechsten Schritt wieder darueber"); ContextInvariants.AssertValid(messages); } [Fact] public async Task Der_Zusammenfassungs_Aufruf_wird_verbucht() { var (compactor, _) = Erzeuge(); var gebucht = new List(); var messages = Conversation.Start().Repeat(15).Build(); await compactor.CompactIfNeededAsync( messages, new LoopGuardConfig { MaxContextTokens = 1_000, CompactionThreshold = 0.5 }, Model, default, gebucht.Add); gebucht.ShouldHaveSingleItem().PromptTokens.ShouldBe(100); } [Fact] public async Task Unter_der_Schwelle_wird_nichts_verbucht() { var (compactor, client) = Erzeuge(); var gebucht = new List(); await compactor.CompactIfNeededAsync( Conversation.Start().Repeat(2).Build(), new LoopGuardConfig { MaxContextTokens = 10_000_000, CompactionThreshold = 0.9 }, Model, default, gebucht.Add); gebucht.ShouldBeEmpty(); client.CallCount.ShouldBe(0); } }