using ClawdDotNet.Core.Engine; using ClawdDotNet.Core.Tests.Infrastructure; using Shouldly; namespace ClawdDotNet.Core.Tests.Engine; /// /// #203 — die Compaction lief nach dem API-Aufruf. /// /// Sie stützte sich auf die promptTokens der gerade beantworteten Anfrage: /// Als der Wert vorlag, war der überfüllte Prompt längst abgerechnet, und gekürzt wurde /// erst für den nächsten Schritt. Genau ein Schritt zu spät — und zwar der teuerste. /// /// Die Zusage lautet jetzt: Keine Anfrage geht über die Kontextgrenze hinaus. /// Geprüft wird vor dem Senden, mit der Schätzung; die gemessenen Werte ziehen die /// Schätzung hinterher nach. /// public sealed class ProactiveCompactionTests { private const string AgentModel = "test/model"; private const string SummaryModel = "test/summary"; [Fact] public async Task Keine_Anfrage_geht_ueber_die_Kontextgrenze_hinaus() { var fixture = new EngineFixture(); var agent = fixture.AddAgent("agent-a"); // Schwelle: 1000 geschätzte Tokens. agent.LoopGuard.MaxContextTokens = 2_000; agent.LoopGuard.CompactionThreshold = 0.5; agent.LoopGuard.SummaryModel = SummaryModel; fixture.Client.AlwaysRespondsWithText("Verstanden."); // Jede Nachricht bringt rund 150 geschaetzte Tokens mit; nach wenigen Runden // waere der Kontext ohne Eingriff ueber der Grenze. Klein genug, dass auch die // sechs geschuetzten Tail-Nachrichten zusammen noch unter die Grenze passen - // sonst koennte selbst eine Kompaktierung sie nicht mehr unterschreiten. for (var i = 0; i < 20; i++) { await fixture.Engine.ChatAsync( agent, $"Frage {i}: " + new string('y', 600), "test-instance", default); } var anfragenAnDenAgenten = fixture.Client.ReceivedRequests .Where(r => r.Model == AgentModel) .ToList(); anfragenAnDenAgenten.Count.ShouldBe(20); // Der eigentliche Punkt: nicht "irgendwann wurde gekürzt", sondern "es ging nie // etwas Überfülltes hinaus". Vorher hing die Prüfung an den promptTokens der // Antwort — beim Fake immer 100 —, kam also nie über die Schwelle und kürzte nie. foreach (var anfrage in anfragenAnDenAgenten) { ContextCompactor.EstimateTokens(anfrage.Messages) .ShouldBeLessThan(agent.LoopGuard.MaxContextTokens); } } [Fact] public async Task Gekuerzt_wird_mit_dem_guenstigen_Modell_bevor_gesendet_wird() { var fixture = new EngineFixture(); var agent = fixture.AddAgent("agent-a"); agent.LoopGuard.MaxContextTokens = 2_000; agent.LoopGuard.CompactionThreshold = 0.5; agent.LoopGuard.SummaryModel = SummaryModel; fixture.Client.AlwaysRespondsWithText("Verstanden."); for (var i = 0; i < 20; i++) { await fixture.Engine.ChatAsync( agent, $"Frage {i}: " + new string('y', 600), "test-instance", default); } // Es wurde überhaupt kompaktiert — sonst sagt der Test darüber nichts aus. fixture.Client.ReceivedRequests.ShouldContain(r => r.Model == SummaryModel); } // ═══════════════════════════════════════════════════════════ // Kalibrierung // ═══════════════════════════════════════════════════════════ private static ContextCompactor NeuerKompaktierer() => new(new FakeChatClient(), TestLogging.Factory); [Fact] public void Ohne_Messwerte_gilt_die_rohe_Schaetzung() { var messages = Conversation.Start().Repeat(4).Build(); NeuerKompaktierer().EstimateFor("m", messages) .ShouldBe(ContextCompactor.EstimateTokens(messages)); } [Fact] public void Eine_unterschaetzte_Anfrage_hebt_die_Schaetzung_an() { var messages = Conversation.Start().Repeat(4).Build(); var roh = ContextCompactor.EstimateTokens(messages); var compactor = NeuerKompaktierer(); // Die API berechnet durchweg doppelt so viel wie geschätzt. for (var i = 0; i < 20; i++) compactor.Calibrate("m", 1_000, 2_000); compactor.EstimateFor("m", messages).ShouldBeGreaterThan(roh); } [Fact] public void Ein_einzelner_Ausreisser_kippt_die_Schwelle_nicht() { var messages = Conversation.Start().Repeat(4).Build(); var roh = ContextCompactor.EstimateTokens(messages); var compactor = NeuerKompaktierer(); compactor.Calibrate("m", 1_000, 4_000); // einmal weit daneben // Ein Messwert verschiebt den Faktor nur um sein Gewicht, nicht auf seinen Wert. compactor.EstimateFor("m", messages).ShouldBeLessThan(roh * 2); } [Fact] public void Ein_absurder_Messwert_wird_gedeckelt() { var messages = Conversation.Start().Repeat(4).Build(); var roh = ContextCompactor.EstimateTokens(messages); var compactor = NeuerKompaktierer(); for (var i = 0; i < 50; i++) compactor.Calibrate("m", 1, 1_000_000); // Höchstens das Vierfache — was darüber läge, ist kein Kalibrierungsfall mehr. compactor.EstimateFor("m", messages).ShouldBeLessThanOrEqualTo(roh * 4); } [Fact] public void Jedes_Modell_wird_fuer_sich_kalibriert() { // Verschiedene Tokenizer zerlegen denselben Text verschieden — ein gemeinsamer // Faktor wäre der Mittelwert von etwas, das nichts miteinander zu tun hat. var messages = Conversation.Start().Repeat(4).Build(); var roh = ContextCompactor.EstimateTokens(messages); var compactor = NeuerKompaktierer(); for (var i = 0; i < 20; i++) compactor.Calibrate("gross", 1_000, 3_000); compactor.EstimateFor("gross", messages).ShouldBeGreaterThan(roh); compactor.EstimateFor("unberuehrt", messages).ShouldBe(roh); } [Fact] public void Unbrauchbare_Messwerte_werden_verworfen() { var messages = Conversation.Start().Repeat(4).Build(); var roh = ContextCompactor.EstimateTokens(messages); var compactor = NeuerKompaktierer(); compactor.Calibrate("m", 0, 5_000); // ohne Schätzung kein Verhältnis compactor.Calibrate("m", 1_000, 0); // ohne Messwert auch nicht compactor.EstimateFor("m", messages).ShouldBe(roh); } }