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));