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
+83
View File
@@ -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]