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 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-23 20:29:47 +02:00
co-authored by Claude Opus 4.8
parent 039bc240f8
commit bb103a578a
7 changed files with 275 additions and 8 deletions
+24
View File
@@ -0,0 +1,24 @@
using System;
namespace PolyTrader.Core.Agents
{
/// <summary>
/// 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 <see cref="LoopGuard"/>
/// erzwungen so kann ein LLM-Agent nicht in eine teure Endlosschleife laufen.
/// </summary>
public sealed record AgentBudget
{
/// <summary>Maximale Anzahl Modell-Runden (Function-Calling-Iterationen).</summary>
public int MaxSteps { get; init; } = 8;
/// <summary>Maximale Summe aus Prompt- und Completion-Tokens über den gesamten Lauf.</summary>
public int MaxTokens { get; init; } = 120_000;
/// <summary>Maximale Wanduhr-Dauer des gesamten Laufs.</summary>
public TimeSpan Timeout { get; init; } = TimeSpan.FromMinutes(3);
/// <summary>Voreinstellung (moderat: 8 Schritte, 120k Tokens, 3 Minuten).</summary>
public static AgentBudget Default { get; } = new();
}
}
+71
View File
@@ -0,0 +1,71 @@
using System;
using System.Diagnostics;
using System.Threading;
namespace PolyTrader.Core.Agents
{
/// <summary>Welche Budget-Dimension überschritten wurde.</summary>
public enum AgentBudgetKind
{
Steps,
Tokens,
Time
}
/// <summary>Wird geworfen, sobald eine Budget-Dimension eines Agenten-Laufs überschritten ist.</summary>
public sealed class AgentBudgetExceededException : Exception
{
public AgentBudgetKind Kind { get; }
public AgentBudgetExceededException(AgentBudgetKind kind, string message) : base(message)
{
Kind = kind;
}
}
/// <summary>
/// Erzwingt ein <see cref="AgentBudget"/> 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.
/// </summary>
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;
/// <summary>Zählt eine Modell-Runde. Wirft bei Überschreitung der Schrittzahl oder des Zeitbudgets.</summary>
public void RecordStep()
{
if (Interlocked.Increment(ref _steps) > _budget.MaxSteps)
throw new AgentBudgetExceededException(AgentBudgetKind.Steps,
$"Maximale Schrittzahl ({_budget.MaxSteps}) überschritten.");
ThrowIfExpired();
}
/// <summary>Addiert verbrauchte Tokens. Wirft bei Überschreitung des Token-Budgets.</summary>
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}).");
}
/// <summary>Wirft, wenn das Zeitbudget abgelaufen ist.</summary>
public void ThrowIfExpired()
{
if (_clock.Elapsed > _budget.Timeout)
throw new AgentBudgetExceededException(AgentBudgetKind.Time,
$"Zeitbudget ({_budget.Timeout.TotalSeconds:0}s) überschritten.");
}
}
}
@@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
namespace PolyTrader.Core.Agents
{
/// <summary>Wird geworfen, wenn ein Agent ein nicht freigegebenes Tool aufruft.</summary>
public sealed class ToolAccessDeniedException : Exception
{
public ToolAccessDeniedException(string toolName)
: base($"Zugriff auf Tool '{toolName}' ist für diesen Agenten/dieses Profil nicht erlaubt.")
{
}
}
/// <summary>
/// Erzwungene Tool-Allow-List (aus ClawdDotNet übernommen): Defense-in-Depth gegen Modell-Fehlgriffe.
/// Eine <c>null</c>-Allow-List bedeutet „alle Tools erlaubt" (deckt sich mit einem Profil ohne Filter).
/// Der Abgleich ist case-insensitiv.
/// </summary>
public sealed class PermissionGate
{
public bool IsAllowed(string toolName, IReadOnlyCollection<string>? 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<string>? allowedTools)
{
if (!IsAllowed(toolName, allowedTools))
throw new ToolAccessDeniedException(toolName);
}
}
}