K5: Tagesbudget als harte Grenze

Es gab keine Obergrenze. Ein Agent in einer Schleife — etwa durch gegenseitige
send_message-Aufrufe — konnte unbeaufsichtigt Guthaben verbrennen; die
Credits-Anzeige war rein informativ.

Verbrauchsdaten in der Datenbank

Voraussetzung dafuer ist eine belastbare Erfassung. Bisher lag der Verbrauch in
TokenUsage.json: Bei JEDEM Agenten-Lauf wurde die gesamte Datei geladen, ergaenzt
und neu geschrieben, unter einem globalen Lock. Das waechst quadratisch und ist
der eigentliche Engpass bei vielen Agenten — unabhaengig davon, welche Datenbank
darunter liegt.

RunUsage liegt jetzt in einer eigenen Tabelle mit Ortsdatum, damit ein Tagesbudget
der Wahrnehmung des Benutzers folgt und die Abfrage ohne Zeitzonenrechnerei
auskommt. Betraege werden als Text abgelegt und als decimal gelesen: Ueber REAL zu
gehen wuerde bei Cent-Betraegen Rundungsfehler einsammeln, die sich ueber tausende
Laeufe summieren. Ein Test weist das mit 1000 Buchungen zu je 0,0001 USD nach.

BudgetGuard

Zwei Arten von Grenzen, weil sich Kosten nicht immer beziffern lassen: Liefert der
Anbieter fuer ein Modell keine Preise, greift die Kostengrenze nicht — die
Token-Grenze dagegen immer. Wer sich absichern will, setzt beide. Ist die Summe
wegen fehlender Preise unvollstaendig, steht das in der Begruendung; sonst wirkte
ein niedriger Verbrauch wie ein noch offener Spielraum.

Grenzen gibt es je Agent und je Instanz. Geprueft wird vor der ersten Anfrage,
damit ein erschoepftes Budget gar nichts mehr kostet. Neuer Endzustand
BudgetExceeded.

Nebenbei: Der Namespace Usage kollidierte mit der gleichnamigen Modellklasse fuer
Token-Angaben und heisst jetzt Accounting.

357 Tests gruen (209 Core, 148 Tools).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-28 18:16:21 +02:00
co-authored by Claude Opus 4.8
parent 4747835fa1
commit ef3e519f6c
11 changed files with 871 additions and 5 deletions
+99 -3
View File
@@ -2,9 +2,11 @@ using System.Diagnostics;
using System.Text.Json;
using ClawdDotNet.Core.Api;
using ClawdDotNet.Core.Api.Models;
using ClawdDotNet.Core.Budget;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Memory;
using ClawdDotNet.Core.Security;
using ClawdDotNet.Core.Accounting;
using ClawdDotNet.Core.Tools;
using ClawdDotNet.Core.State;
using Microsoft.Extensions.Logging;
@@ -18,7 +20,13 @@ public sealed class AgentEngine : IAgentMessageRouter
private readonly PermissionGate _permissionGate;
private readonly IStateStore _stateStore;
private readonly IMemoryRepository? _memoryRepository;
private readonly IUsageRepository? _usageRepository;
private readonly BudgetGuard? _budgetGuard;
private readonly ModelPricingCatalog? _pricing;
private readonly ILoggerFactory _loggerFactory;
/// <summary>Tagesgrenzen der Instanz. Wird vom Host gesetzt.</summary>
public InstanceBudget InstanceBudget { get; set; } = InstanceBudget.Unlimited;
private readonly ContextCompactor _compactor;
private readonly Dictionary<string, List<ChatEntry>> _chatHistories = new();
@@ -56,7 +64,9 @@ public sealed class AgentEngine : IAgentMessageRouter
PermissionGate permissionGate,
IStateStore stateStore,
ILoggerFactory loggerFactory,
IMemoryRepository? memoryRepository = null)
IMemoryRepository? memoryRepository = null,
IUsageRepository? usageRepository = null,
ModelPricingCatalog? pricing = null)
{
_client = client;
_toolRegistry = toolRegistry;
@@ -64,6 +74,9 @@ public sealed class AgentEngine : IAgentMessageRouter
_stateStore = stateStore;
_loggerFactory = loggerFactory;
_memoryRepository = memoryRepository;
_usageRepository = usageRepository;
_pricing = pricing;
_budgetGuard = usageRepository is null ? null : new BudgetGuard(usageRepository);
_compactor = new ContextCompactor(client, loggerFactory);
}
@@ -87,6 +100,21 @@ public sealed class AgentEngine : IAgentMessageRouter
string userMessage,
string instanceId,
CancellationToken externalCt)
{
// Vor der ersten Anfrage prüfen — ein erschöpftes Budget soll gar nichts kosten.
if (await CheckBudgetAsync(agentConfig, externalCt) is { } denied)
return denied;
var result = await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt);
await RecordUsageAsync(agentConfig, result);
return result;
}
private async Task<AgentRunResult> RunCoreAsync(
AgentConfig agentConfig,
string userMessage,
string instanceId,
CancellationToken externalCt)
{
var logger = _loggerFactory.CreateLogger($"ClawdDotNet.Core.Engine.{agentConfig.AgentId}");
var loopGuard = new LoopGuard(agentConfig.LoopGuard);
@@ -255,6 +283,9 @@ public sealed class AgentEngine : IAgentMessageRouter
CancellationToken externalCt,
string? source = null)
{
if (await CheckBudgetAsync(agentConfig, externalCt) is { } denied)
return denied;
// Abbrechbar sein, schon bevor der Lauf an der Reihe ist — sonst hängt eine
// wartende Nachricht auch dann noch, wenn der Benutzer längst abgebrochen hat.
using var runCts = CancellationTokenSource.CreateLinkedTokenSource(externalCt);
@@ -275,7 +306,9 @@ public sealed class AgentEngine : IAgentMessageRouter
try
{
return await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source);
var result = await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source);
await RecordUsageAsync(agentConfig, result);
return result;
}
finally
{
@@ -840,6 +873,68 @@ public sealed class AgentEngine : IAgentMessageRouter
}
}
// ─── Budget und Verbrauchserfassung ───
/// <summary>
/// Prüft das Tagesbudget. Gibt ein Ergebnis zurück, wenn der Lauf nicht stattfinden
/// darf — sonst null.
/// </summary>
private async Task<AgentRunResult?> CheckBudgetAsync(AgentConfig agentConfig, CancellationToken ct)
{
if (_budgetGuard is null)
return null;
var status = await _budgetGuard.CheckAsync(agentConfig, InstanceBudget, ct);
if (status.IsAllowed)
return null;
var logger = _loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Budget");
logger.LogWarning("Lauf abgelehnt für {AgentId}: {Reason}", agentConfig.AgentId, status.Reason);
var result = new AgentRunResult(
agentConfig.AgentId, AgentRunStatus.BudgetExceeded,
$"[Budget erschöpft] {status.Reason}", 0, 0, TimeSpan.Zero);
OnRunCompleted?.Invoke(agentConfig.Model, result);
return result;
}
/// <summary>
/// Schreibt den Verbrauch eines Laufs fort. Fehler hierbei dürfen den Lauf nicht
/// nachträglich scheitern lassen — die eigentliche Arbeit ist bereits getan.
/// </summary>
private async Task RecordUsageAsync(AgentConfig agentConfig, AgentRunResult result)
{
if (_usageRepository is null)
return;
try
{
var estimate = _pricing?.Estimate(agentConfig.Model, result.PromptTokens, result.CompletionTokens);
await _usageRepository.RecordAsync(new RunUsage
{
AgentId = agentConfig.AgentId,
Model = agentConfig.Model,
PromptTokens = result.PromptTokens,
CompletionTokens = result.CompletionTokens,
CachedTokens = result.CachedTokens,
CostUsd = estimate?.Usd ?? 0m,
CostIsKnown = estimate?.IsKnown ?? false,
Status = result.Status.ToString(),
StepCount = result.StepCount,
DurationMs = (long)result.Duration.TotalMilliseconds,
OccurredAt = DateTime.Now
}, CancellationToken.None);
}
catch (Exception ex)
{
_loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Usage")
.LogWarning(ex, "Verbrauch konnte nicht festgehalten werden für {AgentId}",
agentConfig.AgentId);
}
}
/// <summary>Sammelt die Token-Zahlen über alle Schritte eines Runs.</summary>
private sealed class TokenTally
{
@@ -848,7 +943,8 @@ public sealed class AgentEngine : IAgentMessageRouter
public int Completion { get; private set; }
public int Cached { get; private set; }
public void Add(Usage usage)
// Voll qualifiziert: "Usage" ist auch ein Namespace (ClawdDotNet.Core.Accounting).
public void Add(Api.Models.Usage usage)
{
Total += usage.TotalTokens;
Prompt += usage.PromptTokens;