Files
RichardandClaude Opus 4.8 eae13771cf B1, B14 und B3 beheben: Compaction-Paarung und Token-Semantik
B1 — Die Compaction behielt blind die letzten 6 Nachrichten. Fiel diese Grenze
mitten in eine Tool-Sequenz, entstand eine tool-Antwort ohne zugehoerigen
assistant-tool_call; die API lehnt das mit HTTP 400 ab. FindSafeTailStart
verschiebt die Grenze jetzt rueckwaerts auf eine Blockgrenze.

B14 — Bei Konversationen mit hoechstens 6 Nachrichten enthielt der Tail auch die
system-Nachricht, die anschliessend ein zweites Mal angehaengt wurde. Ergebnis
war ein doppelter System-Prompt und eine duplizierte Konversation — die
Compaction vergroesserte den Kontext, statt ihn zu verkleinern. Der Tail beginnt
nun grundsaetzlich hinter dem System-Prompt; liegt davor nichts Nennenswertes,
wird die Kompaktierung uebersprungen. Gefunden durch den Property-Test.

Nebeneffekt: Zusammengefasst wird nur noch der Teil, der tatsaechlich wegfaellt.
Der Tail bleibt woertlich erhalten und musste bisher doppelt bezahlt werden.

B3 — maxTokens zaehlte kumulativ ueber alle Schritte, wurde aber wie eine
Kontextgrenze konfiguriert. Da jeder Schritt den vollen Kontext erneut sendet,
brach ein Chat mit 20k Kontext nach vier Schritten ab. Aufgeteilt in
maxCumulativeTokens (Kostenbudget, Default 500k) und maxContextTokens
(Kontextgroesse). Alte Konfigurationen werden beim Laden migriert, die
Fehlermeldungen unterscheiden jetzt Schritt- und Kostenlimit.

Alle 31 Tests gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 10:15:50 +02:00

113 lines
4.6 KiB
C#

using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Engine;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Engine;
public sealed class LoopGuardTests
{
// ═══════════════════════════════════════════════════════════
// L1 — Schrittzählung
// ═══════════════════════════════════════════════════════════
[Fact]
public void RecordStep_erlaubt_genau_MaxSteps_Schritte()
{
var guard = new LoopGuard(new LoopGuardConfig { MaxSteps = 5 });
for (var i = 0; i < 5; i++)
guard.RecordStep();
guard.Steps.ShouldBe(5);
Should.Throw<LoopLimitExceededException>(() => guard.RecordStep());
}
[Fact]
public void RecordStep_nennt_das_erreichte_Limit_in_der_Meldung()
{
var guard = new LoopGuard(new LoopGuardConfig { MaxSteps = 3 });
for (var i = 0; i < 3; i++) guard.RecordStep();
var ex = Should.Throw<LoopLimitExceededException>(() => guard.RecordStep());
ex.Message.ShouldContain("3");
}
// ═══════════════════════════════════════════════════════════
// L2 — Die maxTokens-Semantik (Bug B3)
// ═══════════════════════════════════════════════════════════
/// <summary>
/// Kernproblem B3: RecordTokens summiert die TotalTokens jedes Schritts.
/// Da jeder Schritt den kompletten Kontext erneut sendet, wächst diese Summe
/// quadratisch — ein völlig normaler Chat bricht dadurch nach wenigen Schritten ab.
///
/// Dieser Test beschreibt das GEWÜNSCHTE Verhalten: Ein Agent mit einem stabilen
/// 20k-Kontext muss 10 Schritte durchhalten können.
///
/// Seit der Trennung von maxCumulativeTokens (Kostenbudget) und
/// maxContextTokens (Kontextgröße) läuft er durch.
/// </summary>
[Fact]
public void Ein_stabiler_Kontext_ueberlebt_zehn_Schritte()
{
var config = new LoopGuardConfig(); // bewusst die Produktiv-Defaults
var guard = new LoopGuard(config);
Should.NotThrow(() =>
{
for (var step = 0; step < 10; step++)
{
guard.RecordStep();
// Realistisch: 20k Kontext geht rein, ~500 Tokens kommen raus.
guard.RecordTokens(20_000 + 500);
}
}, $"Ein Kontext von 20k über 10 Schritte ist normal und darf nicht am " +
$"Kostenbudget {config.MaxCumulativeTokens} scheitern.");
}
[Fact]
public void RecordTokens_greift_wenn_das_Kostenbudget_wirklich_erschoepft_ist()
{
var guard = new LoopGuard(new LoopGuardConfig { MaxCumulativeTokens = 1_000 });
Should.Throw<LoopLimitExceededException>(() => guard.RecordTokens(1_001));
}
[Fact]
public void Die_Meldung_unterscheidet_Schritt_und_Kostenlimit()
{
var stepGuard = new LoopGuard(new LoopGuardConfig { MaxSteps = 1 });
stepGuard.RecordStep();
Should.Throw<LoopLimitExceededException>(() => stepGuard.RecordStep())
.Message.ShouldContain("Schritt-Limit");
var tokenGuard = new LoopGuard(new LoopGuardConfig { MaxCumulativeTokens = 100 });
Should.Throw<LoopLimitExceededException>(() => tokenGuard.RecordTokens(101))
.Message.ShouldContain("Kostenbudget");
}
// ═══════════════════════════════════════════════════════════
// L3 — Thread-Sicherheit
// ═══════════════════════════════════════════════════════════
[Fact]
public void RecordStep_zaehlt_unter_Parallelzugriff_korrekt()
{
var guard = new LoopGuard(new LoopGuardConfig { MaxSteps = int.MaxValue });
Parallel.For(0, 1_000, _ => guard.RecordStep());
guard.Steps.ShouldBe(1_000);
}
[Fact]
public void RecordTokens_summiert_unter_Parallelzugriff_korrekt()
{
var guard = new LoopGuard(new LoopGuardConfig { MaxCumulativeTokens = int.MaxValue });
Parallel.For(0, 1_000, _ => guard.RecordTokens(10));
guard.Tokens.ShouldBe(10_000);
}
}