From eae13771cf58058967591b63cba894fa7031f7ed Mon Sep 17 00:00:00 2001 From: Richard Date: Mon, 27 Jul 2026 10:15:50 +0200 Subject: [PATCH] B1, B14 und B3 beheben: Compaction-Paarung und Token-Semantik MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Models/AgentSettingsViewModel.cs | 13 ++-- Services/InstanceDirectoryManager.cs | 1 + docs/Bestandsaufnahme-2026-07.md | 5 +- src/ClawdDotNet.Core/Config/AgentConfig.cs | 20 +++++- src/ClawdDotNet.Core/Config/ConfigLoader.cs | 31 +++++++++ .../Engine/ContextCompactor.cs | 52 ++++++++++++-- src/ClawdDotNet.Core/Engine/LoopGuard.cs | 16 ++++- .../AgentEditorTool.cs | 2 +- .../Config/ConfigMigrationTests.cs | 68 +++++++++++++++++++ .../Engine/LoopGuardTests.cs | 24 +++++-- 10 files changed, 209 insertions(+), 23 deletions(-) create mode 100644 tests/ClawdDotNet.Core.Tests/Config/ConfigMigrationTests.cs diff --git a/Models/AgentSettingsViewModel.cs b/Models/AgentSettingsViewModel.cs index 1673b0c..a1c7253 100644 --- a/Models/AgentSettingsViewModel.cs +++ b/Models/AgentSettingsViewModel.cs @@ -66,12 +66,15 @@ public sealed class AgentSettingsViewModel } [Category("2 - Loop-Schutz")] - [DisplayName("Max. Tokens pro Run")] - [Description("Maximale Token-Anzahl pro einzelnem Run (Summe aller Schritte). Schützt vor unkontrollierten Kosten.")] - public int MaxTokens + [DisplayName("Kostenbudget pro Run (Tokens)")] + [Description("Summe ALLER abgerechneten Tokens eines Runs über alle Schritte. " + + "Da jeder Schritt den kompletten Kontext erneut sendet, wächst diese Summe " + + "überproportional — der Wert liegt deutlich über der Kontextgröße. " + + "Für die Kontextgröße ist 'Max. Kontext-Tokens' zuständig.")] + public int MaxCumulativeTokens { - get => _config.LoopGuard.MaxTokens; - set => _config.LoopGuard.MaxTokens = Math.Max(1000, value); + get => _config.LoopGuard.MaxCumulativeTokens; + set => _config.LoopGuard.MaxCumulativeTokens = Math.Max(10_000, value); } [Category("2 - Loop-Schutz")] diff --git a/Services/InstanceDirectoryManager.cs b/Services/InstanceDirectoryManager.cs index d60670d..ab51992 100644 --- a/Services/InstanceDirectoryManager.cs +++ b/Services/InstanceDirectoryManager.cs @@ -270,6 +270,7 @@ public sealed class InstanceDirectoryManager { var json = File.ReadAllText(settingsPath); config = JsonSerializer.Deserialize(json, JsonOpts) ?? new AgentConfig(); + ConfigLoader.Migrate(config); } else { diff --git a/docs/Bestandsaufnahme-2026-07.md b/docs/Bestandsaufnahme-2026-07.md index 66111f9..0d8d49d 100644 --- a/docs/Bestandsaufnahme-2026-07.md +++ b/docs/Bestandsaufnahme-2026-07.md @@ -518,8 +518,9 @@ Siehe K3. ## 6. Vorgeschlagene Reihenfolge **Sofort — es blockiert oder gefährdet den Betrieb** -1. B1 Compaction-Paarung (bricht produktiv ab) -2. B3 `maxTokens`-Semantik (bricht produktiv ab) +1. ~~B1 Compaction-Paarung (bricht produktiv ab)~~ ✅ behoben +2. ~~B3 `maxTokens`-Semantik (bricht produktiv ab)~~ ✅ behoben +2b. ~~B14 System-Prompt-Duplikat~~ ✅ behoben 3. B2 Race Condition im Chat-Kontext 4. S2 yt-dlp-Injection 5. S3 API-Key-Leak diff --git a/src/ClawdDotNet.Core/Config/AgentConfig.cs b/src/ClawdDotNet.Core/Config/AgentConfig.cs index 60b9e30..ef4658f 100644 --- a/src/ClawdDotNet.Core/Config/AgentConfig.cs +++ b/src/ClawdDotNet.Core/Config/AgentConfig.cs @@ -107,8 +107,26 @@ public sealed class LoopGuardConfig [JsonPropertyName("maxSteps")] public int MaxSteps { get; set; } = 20; + /// + /// Kostenbudget für einen kompletten Run: Summe der abgerechneten Tokens über + /// ALLE Schritte hinweg. + /// + /// Achtung — dieser Wert ist nicht mit der Kontextgröße zu verwechseln: Da jeder + /// Schritt den vollständigen Kontext erneut sendet, wächst die Summe quadratisch. + /// Ein Agent mit stabilem 20k-Kontext verbraucht über 10 Schritte bereits ~200k. + /// Der Default ist deshalb bewusst großzügig; für die Kontextgröße ist + /// zuständig. + /// + [JsonPropertyName("maxCumulativeTokens")] + public int MaxCumulativeTokens { get; set; } = 500_000; + + /// + /// Veraltet: hieß früher "maxTokens" und wurde fälschlich als Kontextgrenze + /// verstanden, obwohl er kumulativ zählt. Wird nur noch für die Migration alter + /// Konfigurationen gelesen — siehe . + /// [JsonPropertyName("maxTokens")] - public int MaxTokens { get; set; } = 80_000; + public int? LegacyMaxTokens { get; set; } [JsonPropertyName("timeoutSeconds")] public int TimeoutSeconds { get; set; } = 600; diff --git a/src/ClawdDotNet.Core/Config/ConfigLoader.cs b/src/ClawdDotNet.Core/Config/ConfigLoader.cs index d9fb475..b26a061 100644 --- a/src/ClawdDotNet.Core/Config/ConfigLoader.cs +++ b/src/ClawdDotNet.Core/Config/ConfigLoader.cs @@ -20,10 +20,41 @@ public static class ConfigLoader var config = await JsonSerializer.DeserializeAsync(stream, JsonOptions, ct) ?? throw new InvalidOperationException($"Config file is empty or invalid: {filePath}"); + Migrate(config); Validate(config, filePath); return config; } + /// + /// Hebt alte Konfigurationen auf das aktuelle Schema. + /// + /// "maxTokens" wurde früher als Kontextgrenze verstanden, zählte aber kumulativ + /// über alle Schritte — dadurch brachen normale Läufe vorzeitig ab. Der Wert wird + /// nicht übernommen, sondern durch den großzügigen Default für das Kostenbudget + /// ersetzt; die Kontextsteuerung übernimmt maxContextTokens. + /// + public static void Migrate(InstanceConfig config) + { + foreach (var agent in config.Agents) + Migrate(agent); + } + + /// + /// Migration für einen einzeln geladenen Agenten (AgentSettings.json). + /// + public static void Migrate(AgentConfig agent) + { + var guard = agent.LoopGuard; + if (guard.LegacyMaxTokens is not { } legacy) + return; + + // Nur übernehmen, wenn bewusst großzügiger konfiguriert als der Default. + if (legacy > guard.MaxCumulativeTokens) + guard.MaxCumulativeTokens = legacy; + + guard.LegacyMaxTokens = null; + } + private static void Validate(InstanceConfig config, string filePath) { if (string.IsNullOrWhiteSpace(config.OpenRouterApiKey)) diff --git a/src/ClawdDotNet.Core/Engine/ContextCompactor.cs b/src/ClawdDotNet.Core/Engine/ContextCompactor.cs index 604597c..0fc099d 100644 --- a/src/ClawdDotNet.Core/Engine/ContextCompactor.cs +++ b/src/ClawdDotNet.Core/Engine/ContextCompactor.cs @@ -95,12 +95,55 @@ public sealed class ContextCompactor return pruned; } + /// + /// Sucht einen Index, an dem der Kontext geteilt werden darf, ohne eine Tool-Sequenz + /// zu zerreißen. + /// + /// Eine tool-Nachricht darf nie am Anfang des behaltenen Teils stehen — ihr + /// assistant-Aufruf stünde sonst nicht mehr davor und die API lehnt die + /// gesamte Anfrage mit HTTP 400 ab. Deshalb wandert die Grenze rückwärts bis zum + /// Beginn des Blocks, in den sie fällt. + /// + private static int FindSafeTailStart(List messages, int desiredStart, int minIndex) + { + var i = Math.Clamp(desiredStart, minIndex, messages.Count); + + // Rückwärts, bis der Anfang keine verwaiste Tool-Antwort mehr ist. + while (i > minIndex && i < messages.Count && messages[i].Role == "tool") + i--; + + return i; + } + private async Task CompactViaLlmAsync( List messages, string model, CancellationToken ct) { var systemMsg = messages.FirstOrDefault(m => m.Role == "system"); + + // Der System-Prompt wird separat behandelt und darf nie Teil des Tails werden, + // sonst landet er doppelt im Kontext. + var firstNonSystem = systemMsg is null ? 0 : messages.IndexOf(systemMsg) + 1; + + var tailStart = FindSafeTailStart( + messages, + messages.Count - ProtectedTailMessages, + firstNonSystem); + + // Wenn vor dem geschützten Ende nichts Nennenswertes liegt, gibt es nichts + // zusammenzufassen — eine Kompaktierung würde den Kontext sogar vergrößern. + if (tailStart - firstNonSystem < 2) + { + _logger.LogInformation( + "Compaction übersprungen: nur {Count} Nachricht(en) vor dem geschützten Ende", + tailStart - firstNonSystem); + return; + } + + // Nur den Teil zusammenfassen, der tatsächlich wegfällt — der Tail bleibt + // ohnehin wörtlich erhalten und müsste sonst doppelt bezahlt werden. var conversationParts = messages - .Where(m => m.Role != "system") + .Take(tailStart) + .Skip(firstNonSystem) .Select(FormatMessageForSummary); var conversationText = string.Join("\n", conversationParts); @@ -136,10 +179,9 @@ public sealed class ContextCompactor return; } - // Nachrichten ersetzen: System-Prompt + Zusammenfassung + geschützte letzte Nachrichten - var tail = messages - .Skip(Math.Max(0, messages.Count - ProtectedTailMessages)) - .ToList(); + // Nachrichten ersetzen: System-Prompt + Zusammenfassung + geschützte letzte Nachrichten. + // tailStart liegt garantiert auf einer Blockgrenze und hinter dem System-Prompt. + var tail = messages.Skip(tailStart).ToList(); messages.Clear(); diff --git a/src/ClawdDotNet.Core/Engine/LoopGuard.cs b/src/ClawdDotNet.Core/Engine/LoopGuard.cs index 8169f41..dee7ce6 100644 --- a/src/ClawdDotNet.Core/Engine/LoopGuard.cs +++ b/src/ClawdDotNet.Core/Engine/LoopGuard.cs @@ -16,13 +16,23 @@ public sealed class LoopGuard public void RecordStep() { if (Interlocked.Increment(ref _steps) > _cfg.MaxSteps) - throw new LoopLimitExceededException($"Max steps ({_cfg.MaxSteps}) exceeded."); + throw new LoopLimitExceededException( + $"Schritt-Limit erreicht: {_cfg.MaxSteps} Schritte pro Run."); } + /// + /// Bucht die abgerechneten Tokens eines Schritts auf das Kostenbudget des Runs. + /// Das ist eine reine Kostenbremse — die Kontextgröße wird davon getrennt über + /// und die Compaction gesteuert. + /// public void RecordTokens(int count) { - if (Interlocked.Add(ref _tokens, count) > _cfg.MaxTokens) - throw new LoopLimitExceededException($"Max tokens ({_cfg.MaxTokens}) exceeded."); + var total = Interlocked.Add(ref _tokens, count); + if (total > _cfg.MaxCumulativeTokens) + throw new LoopLimitExceededException( + $"Kostenbudget erschöpft: {total:N0} von maximal {_cfg.MaxCumulativeTokens:N0} " + + "abgerechneten Tokens in diesem Run. Hinweis: Jeder Schritt sendet den " + + "kompletten Kontext erneut, die Summe wächst daher überproportional."); } } diff --git a/src/ClawdDotNet.Tools.AgentEditor/AgentEditorTool.cs b/src/ClawdDotNet.Tools.AgentEditor/AgentEditorTool.cs index 2d955ba..d533ea2 100644 --- a/src/ClawdDotNet.Tools.AgentEditor/AgentEditorTool.cs +++ b/src/ClawdDotNet.Tools.AgentEditor/AgentEditorTool.cs @@ -329,7 +329,7 @@ public sealed class AgentEditorTool : IAgentTool loopGuard = new { maxSteps = 20, - maxTokens = 80000, + maxCumulativeTokens = 500000, timeoutSeconds = 600, maxContextTokens = 100000, compactionThreshold = 0.8 diff --git a/tests/ClawdDotNet.Core.Tests/Config/ConfigMigrationTests.cs b/tests/ClawdDotNet.Core.Tests/Config/ConfigMigrationTests.cs new file mode 100644 index 0000000..ac50f1a --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Config/ConfigMigrationTests.cs @@ -0,0 +1,68 @@ +using ClawdDotNet.Core.Config; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Config; + +/// +/// 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. +/// +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); + } +} diff --git a/tests/ClawdDotNet.Core.Tests/Engine/LoopGuardTests.cs b/tests/ClawdDotNet.Core.Tests/Engine/LoopGuardTests.cs index 48cddcd..f5f17c9 100644 --- a/tests/ClawdDotNet.Core.Tests/Engine/LoopGuardTests.cs +++ b/tests/ClawdDotNet.Core.Tests/Engine/LoopGuardTests.cs @@ -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. /// [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(() => 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(() => stepGuard.RecordStep()) + .Message.ShouldContain("Schritt-Limit"); + + var tokenGuard = new LoopGuard(new LoopGuardConfig { MaxCumulativeTokens = 100 }); + Should.Throw(() => 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));