From bb103a578a94fe284142165f75e1c4f9891a783a Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 23 Jul 2026 20:29:47 +0200 Subject: [PATCH] Agenten-Kostendeckel: LoopGuard + PermissionGate aus ClawdDotNet uebernommen - Neu PolyTrader.Core/Agents: AgentBudget (Steps/Tokens/Timeout), LoopGuard (thread-safe, AgentBudgetExceededException mit Kind), PermissionGate (Tool-Allow-List, null = alle erlaubt). Aus ClawdDotNet portiert, NICHT als Abhaengigkeit (.NET 10 vs 8). - SupervisorAgent nutzt LoopGuard (Default-Steps = MaxIterations=8, rueckwaerts- kompatibel) + PermissionGate (Allow-List = angebotene Tools; nicht freigegebene Calls liefern Fehlertext statt Ausfuehrung). SupervisorProfile.Budget ueber- schreibt den Deckel. Abbruch graceful mit Grund (Steps/Tokens/Zeit). - Tests: AgentGuardTests (LoopGuard/PermissionGate) + Token-Abbruch am Agenten. Co-Authored-By: Claude Opus 4.8 --- src/PolyTrader.Core/Agents/AgentBudget.cs | 24 ++++++ src/PolyTrader.Core/Agents/LoopGuard.cs | 71 ++++++++++++++++ src/PolyTrader.Core/Agents/PermissionGate.cs | 37 +++++++++ .../Agent/SupervisorAgent.cs | 45 ++++++++-- .../Agent/SupervisorProfiles.cs | 4 + tests/PolyTrader.Tests/AgentGuardTests.cs | 83 +++++++++++++++++++ .../PolyTrader.Tests/SupervisorAgentTests.cs | 19 +++++ 7 files changed, 275 insertions(+), 8 deletions(-) create mode 100644 src/PolyTrader.Core/Agents/AgentBudget.cs create mode 100644 src/PolyTrader.Core/Agents/LoopGuard.cs create mode 100644 src/PolyTrader.Core/Agents/PermissionGate.cs create mode 100644 tests/PolyTrader.Tests/AgentGuardTests.cs diff --git a/src/PolyTrader.Core/Agents/AgentBudget.cs b/src/PolyTrader.Core/Agents/AgentBudget.cs new file mode 100644 index 0000000..eac833d --- /dev/null +++ b/src/PolyTrader.Core/Agents/AgentBudget.cs @@ -0,0 +1,24 @@ +using System; + +namespace PolyTrader.Core.Agents +{ + /// + /// Kostendeckel für einen einzelnen Agenten-Lauf (aus ClawdDotNet übernommen und erweitert): begrenzt + /// Schritte, Gesamt-Tokens und Wanduhr-Zeit. Wird pro Lauf mit einem frischen + /// erzwungen – so kann ein LLM-Agent nicht in eine teure Endlosschleife laufen. + /// + public sealed record AgentBudget + { + /// Maximale Anzahl Modell-Runden (Function-Calling-Iterationen). + public int MaxSteps { get; init; } = 8; + + /// Maximale Summe aus Prompt- und Completion-Tokens über den gesamten Lauf. + public int MaxTokens { get; init; } = 120_000; + + /// Maximale Wanduhr-Dauer des gesamten Laufs. + public TimeSpan Timeout { get; init; } = TimeSpan.FromMinutes(3); + + /// Voreinstellung (moderat: 8 Schritte, 120k Tokens, 3 Minuten). + public static AgentBudget Default { get; } = new(); + } +} diff --git a/src/PolyTrader.Core/Agents/LoopGuard.cs b/src/PolyTrader.Core/Agents/LoopGuard.cs new file mode 100644 index 0000000..703f1ba --- /dev/null +++ b/src/PolyTrader.Core/Agents/LoopGuard.cs @@ -0,0 +1,71 @@ +using System; +using System.Diagnostics; +using System.Threading; + +namespace PolyTrader.Core.Agents +{ + /// Welche Budget-Dimension überschritten wurde. + public enum AgentBudgetKind + { + Steps, + Tokens, + Time + } + + /// Wird geworfen, sobald eine Budget-Dimension eines Agenten-Laufs überschritten ist. + public sealed class AgentBudgetExceededException : Exception + { + public AgentBudgetKind Kind { get; } + + public AgentBudgetExceededException(AgentBudgetKind kind, string message) : base(message) + { + Kind = kind; + } + } + + /// + /// Erzwingt ein für EINEN Agenten-Lauf (nicht wiederverwenden – pro Lauf neu + /// instanziieren). Aus ClawdDotNet übernommen und um ein Zeitbudget ergänzt. Thread-safe (Interlocked), + /// damit auch parallele Tool-Aufrufe sauber gezählt werden. + /// + public sealed class LoopGuard + { + private readonly AgentBudget _budget; + private readonly Stopwatch _clock = Stopwatch.StartNew(); + private int _steps; + private int _tokens; + + public LoopGuard(AgentBudget budget) => _budget = budget ?? AgentBudget.Default; + + public int Steps => Volatile.Read(ref _steps); + public int Tokens => Volatile.Read(ref _tokens); + public TimeSpan Elapsed => _clock.Elapsed; + + /// Zählt eine Modell-Runde. Wirft bei Überschreitung der Schrittzahl oder des Zeitbudgets. + public void RecordStep() + { + if (Interlocked.Increment(ref _steps) > _budget.MaxSteps) + throw new AgentBudgetExceededException(AgentBudgetKind.Steps, + $"Maximale Schrittzahl ({_budget.MaxSteps}) überschritten."); + ThrowIfExpired(); + } + + /// Addiert verbrauchte Tokens. Wirft bei Überschreitung des Token-Budgets. + public void RecordTokens(int count) + { + if (count <= 0) return; + int total = Interlocked.Add(ref _tokens, count); + if (total > _budget.MaxTokens) + throw new AgentBudgetExceededException(AgentBudgetKind.Tokens, + $"Maximales Token-Budget ({_budget.MaxTokens}) überschritten (verbraucht: {total})."); + } + + /// Wirft, wenn das Zeitbudget abgelaufen ist. + public void ThrowIfExpired() + { + if (_clock.Elapsed > _budget.Timeout) + throw new AgentBudgetExceededException(AgentBudgetKind.Time, + $"Zeitbudget ({_budget.Timeout.TotalSeconds:0}s) überschritten."); + } + } +} diff --git a/src/PolyTrader.Core/Agents/PermissionGate.cs b/src/PolyTrader.Core/Agents/PermissionGate.cs new file mode 100644 index 0000000..ad8006f --- /dev/null +++ b/src/PolyTrader.Core/Agents/PermissionGate.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; + +namespace PolyTrader.Core.Agents +{ + /// Wird geworfen, wenn ein Agent ein nicht freigegebenes Tool aufruft. + public sealed class ToolAccessDeniedException : Exception + { + public ToolAccessDeniedException(string toolName) + : base($"Zugriff auf Tool '{toolName}' ist für diesen Agenten/dieses Profil nicht erlaubt.") + { + } + } + + /// + /// Erzwungene Tool-Allow-List (aus ClawdDotNet übernommen): Defense-in-Depth gegen Modell-Fehlgriffe. + /// Eine null-Allow-List bedeutet „alle Tools erlaubt" (deckt sich mit einem Profil ohne Filter). + /// Der Abgleich ist case-insensitiv. + /// + public sealed class PermissionGate + { + public bool IsAllowed(string toolName, IReadOnlyCollection? allowedTools) + { + if (allowedTools == null) return true; + foreach (var t in allowedTools) + if (string.Equals(t, toolName, StringComparison.OrdinalIgnoreCase)) + return true; + return false; + } + + public void Enforce(string toolName, IReadOnlyCollection? allowedTools) + { + if (!IsAllowed(toolName, allowedTools)) + throw new ToolAccessDeniedException(toolName); + } + } +} diff --git a/src/PolyTrader.Modules.Supervisor/Agent/SupervisorAgent.cs b/src/PolyTrader.Modules.Supervisor/Agent/SupervisorAgent.cs index 44d984a..8e34942 100644 --- a/src/PolyTrader.Modules.Supervisor/Agent/SupervisorAgent.cs +++ b/src/PolyTrader.Modules.Supervisor/Agent/SupervisorAgent.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using PolyTrader.Core.Agents; namespace PolyTrader.Modules.Supervisor.Agent { @@ -23,11 +24,13 @@ namespace PolyTrader.Modules.Supervisor.Agent /// public sealed class SupervisorAgent { + /// Default-Schrittzahl eines Laufs (deckungsgleich mit ). public const int MaxIterations = 8; public const string DefaultModel = "openrouter/auto"; private readonly IChatCompletionClient _chat; private readonly SupervisorToolRegistry _tools; + private readonly PermissionGate _gate = new(); public SupervisorAgent(IChatCompletionClient chat, SupervisorToolRegistry tools) { @@ -62,6 +65,7 @@ namespace PolyTrader.Modules.Supervisor.Agent { var activeProfile = profile ?? SupervisorProfiles.Allgemein; var activeTools = ToolsFor(activeProfile); + var allowedToolNames = activeTools.Select(t => t.Name).ToList(); // Allow-List = angebotene Tools var messages = new List { ChatMessage.System(SystemPrompt(activeProfile)), @@ -71,12 +75,22 @@ namespace PolyTrader.Modules.Supervisor.Agent int promptTokens = 0, completionTokens = 0; string usedModel = string.IsNullOrWhiteSpace(model) ? DefaultModel : model.Trim(); - for (int i = 0; i < MaxIterations; i++) + // Kostendeckel des Laufs: Schritte/Tokens/Zeit (Default-Schritte = MaxIterations, Rückwärtskompatibel). + var budget = activeProfile.Budget ?? new AgentBudget { MaxSteps = MaxIterations }; + var guard = new LoopGuard(budget); + + while (true) { ct.ThrowIfCancellationRequested(); + + try { guard.RecordStep(); } + catch (AgentBudgetExceededException ex) { return Abort(ex); } + var response = await _chat.CompleteAsync(usedModel, messages, activeTools, ct); promptTokens += response.PromptTokens; completionTokens += response.CompletionTokens; + try { guard.RecordTokens(response.PromptTokens + response.CompletionTokens); } + catch (AgentBudgetExceededException ex) { return Abort(ex); } if (response.ToolCalls.Count == 0) { @@ -93,19 +107,34 @@ namespace PolyTrader.Modules.Supervisor.Agent foreach (var call in response.ToolCalls) { progress?.Report($"🔧 {call.Name}({call.ArgumentsJson})"); - string result = _tools.Execute(call.Name, call.ArgumentsJson); + // Gate härtet gegen Modell-Fehlgriffe: nicht freigegebene Tools werden NICHT ausgeführt, + // sondern als Fehlertext ans Modell zurückgegeben (fehlertolerant, wie die Registry selbst). + string result = _gate.IsAllowed(call.Name, allowedToolNames) + ? _tools.Execute(call.Name, call.ArgumentsJson) + : $"FEHLER: Tool '{call.Name}' ist für dieses Profil nicht freigegeben."; invocations.Add((call.Name, call.ArgumentsJson, result)); messages.Add(ChatMessage.ToolResult(call.Id, result)); } } - return new AgentResult + // Kontrollierter Abbruch bei erschöpftem Budget – gibt bisherige Ergebnisse transparent zurück. + AgentResult Abort(AgentBudgetExceededException ex) { - Answer = "Abbruch: maximale Tool-Iterationen erreicht (Frage ggf. eingrenzen).", - ToolInvocations = invocations, - PromptTokens = promptTokens, - CompletionTokens = completionTokens - }; + string reason = ex.Kind switch + { + AgentBudgetKind.Steps => "maximale Tool-Iterationen erreicht", + AgentBudgetKind.Tokens => "Token-Budget erschöpft", + AgentBudgetKind.Time => "Zeitbudget erschöpft", + _ => ex.Message + }; + return new AgentResult + { + Answer = $"Abbruch: {reason} (Frage ggf. eingrenzen).", + ToolInvocations = invocations, + PromptTokens = promptTokens, + CompletionTokens = completionTokens + }; + } } } } diff --git a/src/PolyTrader.Modules.Supervisor/Agent/SupervisorProfiles.cs b/src/PolyTrader.Modules.Supervisor/Agent/SupervisorProfiles.cs index caae4e7..65a7626 100644 --- a/src/PolyTrader.Modules.Supervisor/Agent/SupervisorProfiles.cs +++ b/src/PolyTrader.Modules.Supervisor/Agent/SupervisorProfiles.cs @@ -1,15 +1,19 @@ using System; using System.Collections.Generic; using System.Linq; +using PolyTrader.Core.Agents; namespace PolyTrader.Modules.Supervisor.Agent { /// /// Ein Supervisor-Profil (S-3): Fokus-Anweisung + optionales Tool-Subset über EINER gemeinsamen /// Agent-Infrastruktur (Konzept §4a) — bewusst KEINE Agent-zu-Agent-Orchestrierung. + /// überschreibt den Standard-Kostendeckel des Laufs (null = Standard). /// public sealed record SupervisorProfile(string Name, string PromptAddendum, string[]? ToolFilter) { + public AgentBudget? Budget { get; init; } + public override string ToString() => Name; } diff --git a/tests/PolyTrader.Tests/AgentGuardTests.cs b/tests/PolyTrader.Tests/AgentGuardTests.cs new file mode 100644 index 0000000..cec5738 --- /dev/null +++ b/tests/PolyTrader.Tests/AgentGuardTests.cs @@ -0,0 +1,83 @@ +using System; +using System.Threading; +using PolyTrader.Core.Agents; +using Xunit; + +namespace PolyTrader.Tests +{ + /// + /// Sicherheitsnetz für die aus ClawdDotNet übernommenen Agenten-Bausteine: LoopGuard (Kostendeckel + /// Schritte/Tokens/Zeit) und PermissionGate (Tool-Allow-List). + /// + public class AgentGuardTests + { + // ----- LoopGuard ----- + + [Fact] + public void LoopGuard_throws_when_step_limit_exceeded() + { + var guard = new LoopGuard(new AgentBudget { MaxSteps = 2, MaxTokens = 1_000_000, Timeout = TimeSpan.FromMinutes(5) }); + + guard.RecordStep(); // 1 + guard.RecordStep(); // 2 + var ex = Assert.Throws(() => guard.RecordStep()); // 3 -> Abbruch + Assert.Equal(AgentBudgetKind.Steps, ex.Kind); + } + + [Fact] + public void LoopGuard_throws_when_token_budget_exceeded() + { + var guard = new LoopGuard(new AgentBudget { MaxSteps = 100, MaxTokens = 100, Timeout = TimeSpan.FromMinutes(5) }); + + guard.RecordTokens(60); + var ex = Assert.Throws(() => guard.RecordTokens(60)); // 120 > 100 + Assert.Equal(AgentBudgetKind.Tokens, ex.Kind); + Assert.Equal(120, guard.Tokens); + } + + [Fact] + public void LoopGuard_ignores_non_positive_token_counts() + { + var guard = new LoopGuard(new AgentBudget { MaxTokens = 10 }); + guard.RecordTokens(0); + guard.RecordTokens(-5); + Assert.Equal(0, guard.Tokens); + } + + [Fact] + public void LoopGuard_throws_when_time_budget_exceeded() + { + var guard = new LoopGuard(new AgentBudget { MaxSteps = 100, MaxTokens = 1_000_000, Timeout = TimeSpan.FromMilliseconds(10) }); + + Thread.Sleep(40); + var ex = Assert.Throws(() => guard.ThrowIfExpired()); + Assert.Equal(AgentBudgetKind.Time, ex.Kind); + } + + // ----- PermissionGate ----- + + [Fact] + public void PermissionGate_allows_listed_tool_case_insensitive() + { + var gate = new PermissionGate(); + var allowed = new[] { "read_logs", "query_trades" }; + + Assert.True(gate.IsAllowed("READ_LOGS", allowed)); + Assert.False(gate.IsAllowed("place_order", allowed)); + } + + [Fact] + public void PermissionGate_null_allowlist_permits_everything() + { + var gate = new PermissionGate(); + Assert.True(gate.IsAllowed("anything", null)); + } + + [Fact] + public void PermissionGate_enforce_throws_for_denied_tool() + { + var gate = new PermissionGate(); + Assert.Throws(() => gate.Enforce("place_order", new[] { "read_logs" })); + } + } +} diff --git a/tests/PolyTrader.Tests/SupervisorAgentTests.cs b/tests/PolyTrader.Tests/SupervisorAgentTests.cs index af2e57c..7d3e768 100644 --- a/tests/PolyTrader.Tests/SupervisorAgentTests.cs +++ b/tests/PolyTrader.Tests/SupervisorAgentTests.cs @@ -101,6 +101,25 @@ namespace PolyTrader.Tests Assert.Equal(SupervisorAgent.MaxIterations, result.ToolInvocations.Count); } + [Fact] + public async Task Agent_aborts_when_token_budget_exceeded() + { + // Erste Antwort verbraucht bereits mehr Tokens als das Budget -> Abbruch VOR Tool-Ausführung. + var chat = new ScriptedChatClient( + new ChatResponse { PromptTokens = 80, CompletionTokens = 80, + ToolCalls = { new ToolCall("c1", "echo", "{}") } }); + var agent = new SupervisorAgent(chat, RegistryWithEcho()); + var profile = new SupervisorProfile("Knapp", "", null) + { + Budget = new PolyTrader.Core.Agents.AgentBudget { MaxTokens = 100, MaxSteps = 100 } + }; + + var result = await agent.AskAsync("x", profile: profile); + + Assert.Contains("Token-Budget erschöpft", result.Answer); + Assert.Empty(result.ToolInvocations); + } + // ----- OpenRouter-Serialisierung (pur) ----- [Fact]