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>
This commit is contained in:
Richard
2026-07-27 10:15:50 +02:00
co-authored by Claude Opus 4.8
parent 667cecce25
commit eae13771cf
10 changed files with 209 additions and 23 deletions
@@ -0,0 +1,68 @@
using ClawdDotNet.Core.Config;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Config;
/// <summary>
/// Alte Konfigurationen enthalten "maxTokens" — ein Wert, der als Kontextgrenze gemeint
/// war, aber kumulativ über alle Schritte zählte und Läufe vorzeitig abbrach (Bug B3).
/// Die Migration darf diesen Wert nicht unverändert als Kostenbudget übernehmen.
/// </summary>
public sealed class ConfigMigrationTests
{
private static InstanceConfig WithLegacyMaxTokens(int? legacy) => new()
{
OpenRouterApiKey = "sk-test",
Agents =
[
new AgentConfig
{
AgentId = "test",
LoopGuard = new LoopGuardConfig { LegacyMaxTokens = legacy }
}
]
};
[Fact]
public void Ein_altes_zu_knappes_maxTokens_wird_durch_den_neuen_Default_ersetzt()
{
var config = WithLegacyMaxTokens(80_000); // der alte, problematische Wert
ConfigLoader.Migrate(config);
var guard = config.Agents[0].LoopGuard;
guard.MaxCumulativeTokens.ShouldBe(500_000);
guard.LegacyMaxTokens.ShouldBeNull();
}
[Fact]
public void Ein_bewusst_grosszuegiges_maxTokens_bleibt_erhalten()
{
var config = WithLegacyMaxTokens(2_000_000);
ConfigLoader.Migrate(config);
config.Agents[0].LoopGuard.MaxCumulativeTokens.ShouldBe(2_000_000);
}
[Fact]
public void Ohne_altes_Feld_bleibt_der_Default_unveraendert()
{
var config = WithLegacyMaxTokens(null);
ConfigLoader.Migrate(config);
config.Agents[0].LoopGuard.MaxCumulativeTokens.ShouldBe(500_000);
}
[Fact]
public void Migration_ist_mehrfach_anwendbar()
{
var config = WithLegacyMaxTokens(80_000);
ConfigLoader.Migrate(config);
ConfigLoader.Migrate(config);
config.Agents[0].LoopGuard.MaxCumulativeTokens.ShouldBe(500_000);
}
}
@@ -44,9 +44,8 @@ public sealed class LoopGuardTests
/// Dieser Test beschreibt das GEWÜNSCHTE Verhalten: Ein Agent mit einem stabilen
/// 20k-Kontext muss 10 Schritte durchhalten können.
///
/// Er schlägt mit den aktuellen Defaults fehl und wird grün, sobald
/// maxCumulativeTokens (Kostenbudget) und maxContextTokens (Kontextgröße)
/// getrennt sind.
/// Seit der Trennung von maxCumulativeTokens (Kostenbudget) und
/// maxContextTokens (Kontextgröße) läuft er durch.
/// </summary>
[Fact]
public void Ein_stabiler_Kontext_ueberlebt_zehn_Schritte()
@@ -63,17 +62,30 @@ public sealed class LoopGuardTests
guard.RecordTokens(20_000 + 500);
}
}, $"Ein Kontext von 20k über 10 Schritte ist normal und darf nicht am " +
$"Limit maxTokens={config.MaxTokens} scheitern.");
$"Kostenbudget {config.MaxCumulativeTokens} scheitern.");
}
[Fact]
public void RecordTokens_greift_wenn_das_Kostenbudget_wirklich_erschoepft_ist()
{
var guard = new LoopGuard(new LoopGuardConfig { MaxTokens = 1_000 });
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
// ═══════════════════════════════════════════════════════════
@@ -91,7 +103,7 @@ public sealed class LoopGuardTests
[Fact]
public void RecordTokens_summiert_unter_Parallelzugriff_korrekt()
{
var guard = new LoopGuard(new LoopGuardConfig { MaxTokens = int.MaxValue });
var guard = new LoopGuard(new LoopGuardConfig { MaxCumulativeTokens = int.MaxValue });
Parallel.For(0, 1_000, _ => guard.RecordTokens(10));