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:
co-authored by
Claude Opus 4.8
parent
4747835fa1
commit
ef3e519f6c
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user