Alle Behebungen auf der Linux-Test-VM gegengeprueft (Ubuntu, .NET 10.0.111): Build 0 Warnungen / 0 Fehler, 637 Tests gruen, 1 uebersprungen (WindowsFact). Sicherheit und Zielplattform #373 PathBoundary.Canonicalize fiel offen aus. File.ResolveLinkTarget wirft fuer einen fehlenden Abschnitt FileNotFoundException; der catch(IOException) verliess daraufhin die GANZE Aufloesung mit dem unaufgeloesten Pfad - und der besteht jede Praefixpruefung. Der Symlink-Schutz war damit bei jedem Schreibvorgang wirkungslos. Jetzt ueber FileSystemInfo.LinkTarget, und TryCanonicalize meldet "nicht bestimmbar", statt fail-open durchzureichen. #374 WorkspacePath weist den Backslash rundheraus ab, wie den Doppelpunkt. Eine Schreibweise statt einer Sonderregel je Windows-Form. #375 Log-Verzeichnis: leer heisst "Anwendungseinstellung"; ein relativer Wert zaehlt gegen das Instanzverzeichnis, nicht gegen das Arbeitsverzeichnis des Prozesses. Ein systemd-Dienst landet nicht mehr auf /Logs. Agentenkommunikation #206 Delegationskette gegen A->B->A (Test A13). Zyklusfrei per Bauart, dazu MaxDelegationDepth gegen die Staffel, die niemand beauftragt hat. #461 Zwei unabhaengige Ketten ueber Kreuz - beim Beheben von #206 gefunden und reproduziert. Ein delegierter Lauf wartet nur noch begrenzt auf ein fremdes Gate; ein Chat vom Menschen reiht sich weiter unbegrenzt ein. #234 Instanz-Id wird durchgereicht statt aus einem nur beilaeufig gesetzten Feld gelesen. Betrieb und Oberflaeche #376 Log-Ansicht chronologisch statt gruppiert je Moduldatei, mit Stacktraces an ihrer Meldung - auch ueber Taktgrenzen hinweg. #377 Info-Tabelle: feste Spaltenbreiten, damit ein langer Aenderungstext den anderen Spalten nicht den Platz nimmt. #378 Sicherung: Mindestabstand zwischen Ueberschrift und Schaltflaechenleiste. #379 Hellmodus: drei von vier gedaempften Textpinseln lagen unter 4,5:1. Die helle Palette ist als Ganzes verschoben, damit die Staffelung sichtbar bleibt statt auf einen Wert zusammenzufallen. Hygiene #233 stock_add: der Indexstand wird innerhalb der Sperre festgehalten. #235 Zeitumstellung: eine nicht existierende Ortszeit wird auf den ersten Zeitpunkt nach der Luecke verschoben statt uebersprungen (R4, R5). #236 45 Nullable-Warnungen und eine CA1416 - nicht unterdrueckt, sondern die Stellen so umgebaut, dass die Zusage nachvollziehbar wird. Mail meldet jetzt, WELCHER Zugangsschluessel fehlt, statt an einer KeyNotFoundException zu scheitern. #203 Kompaktiert wird vor dem API-Aufruf statt danach; die gemessenen Prompt-Groessen kalibrieren die Schaetzung je Modell. Neu: tests/ClawdDotNet.App.Tests - fuer #375 und #376 gab es keinen Ort fuer Tests. Dazu Regressionstests fuer jede Behebung; jeder wurde gegen den alten Stand geprueft, damit belegt ist, dass er den Fehler faengt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
171 lines
6.6 KiB
C#
171 lines
6.6 KiB
C#
using ClawdDotNet.Core.Engine;
|
|
using ClawdDotNet.Core.Tests.Infrastructure;
|
|
using Shouldly;
|
|
|
|
namespace ClawdDotNet.Core.Tests.Engine;
|
|
|
|
/// <summary>
|
|
/// #203 — die Compaction lief nach dem API-Aufruf.
|
|
///
|
|
/// <para>Sie stützte sich auf die <c>promptTokens</c> 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.</para>
|
|
///
|
|
/// <para>Die Zusage lautet jetzt: <b>Keine Anfrage geht über die Kontextgrenze hinaus.</b>
|
|
/// Geprüft wird vor dem Senden, mit der Schätzung; die gemessenen Werte ziehen die
|
|
/// Schätzung hinterher nach.</para>
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|