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]