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:
co-authored by
Claude Opus 4.8
parent
039bc240f8
commit
bb103a578a
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/// </summary>
|
||||
public sealed class SupervisorAgent
|
||||
{
|
||||
/// <summary>Default-Schrittzahl eines Laufs (deckungsgleich mit <see cref="AgentBudget.MaxSteps"/>).</summary>
|
||||
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>
|
||||
{
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using PolyTrader.Core.Agents;
|
||||
|
||||
namespace PolyTrader.Modules.Supervisor.Agent
|
||||
{
|
||||
/// <summary>
|
||||
/// Ein Supervisor-Profil (S-3): Fokus-Anweisung + optionales Tool-Subset über EINER gemeinsamen
|
||||
/// Agent-Infrastruktur (Konzept §4a) — bewusst KEINE Agent-zu-Agent-Orchestrierung.
|
||||
/// <see cref="Budget"/> überschreibt den Standard-Kostendeckel des Laufs (null = Standard).
|
||||
/// </summary>
|
||||
public sealed record SupervisorProfile(string Name, string PromptAddendum, string[]? ToolFilter)
|
||||
{
|
||||
public AgentBudget? Budget { get; init; }
|
||||
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using PolyTrader.Core.Agents;
|
||||
using Xunit;
|
||||
|
||||
namespace PolyTrader.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Sicherheitsnetz für die aus ClawdDotNet übernommenen Agenten-Bausteine: LoopGuard (Kostendeckel
|
||||
/// Schritte/Tokens/Zeit) und PermissionGate (Tool-Allow-List).
|
||||
/// </summary>
|
||||
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<AgentBudgetExceededException>(() => 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<AgentBudgetExceededException>(() => 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<AgentBudgetExceededException>(() => 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<ToolAccessDeniedException>(() => gate.Enforce("place_order", new[] { "read_logs" }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user