From ef3e519f6c9e08002f46f7c23b4ada61df91f37c Mon Sep 17 00:00:00 2001 From: Richard Date: Tue, 28 Jul 2026 18:16:21 +0200 Subject: [PATCH] K5: Tagesbudget als harte Grenze MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Models/AgentSettingsViewModel.cs | 22 ++ Program.cs | 16 +- .../Accounting/SqliteUsageRepository.cs | 163 +++++++++ .../Accounting/UsageModels.cs | 58 ++++ src/ClawdDotNet.Core/Budget/BudgetGuard.cs | 121 +++++++ src/ClawdDotNet.Core/Config/AgentConfig.cs | 36 ++ src/ClawdDotNet.Core/Config/InstanceConfig.cs | 4 + src/ClawdDotNet.Core/Engine/AgentEngine.cs | 102 +++++- src/ClawdDotNet.Core/Engine/AgentRunResult.cs | 5 +- src/ClawdDotNet.Core/Storage/SqliteStorage.cs | 24 ++ .../Budget/BudgetGuardTests.cs | 325 ++++++++++++++++++ 11 files changed, 871 insertions(+), 5 deletions(-) create mode 100644 src/ClawdDotNet.Core/Accounting/SqliteUsageRepository.cs create mode 100644 src/ClawdDotNet.Core/Accounting/UsageModels.cs create mode 100644 src/ClawdDotNet.Core/Budget/BudgetGuard.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Budget/BudgetGuardTests.cs diff --git a/Models/AgentSettingsViewModel.cs b/Models/AgentSettingsViewModel.cs index 05eedfb..c820ed5 100644 --- a/Models/AgentSettingsViewModel.cs +++ b/Models/AgentSettingsViewModel.cs @@ -152,6 +152,28 @@ public sealed class AgentSettingsViewModel set => _config.PromptCaching = string.IsNullOrWhiteSpace(value) ? "auto" : value; } + [Category("5 - Kosten")] + [DisplayName("Tagesbudget (USD)")] + [Description("Hartes Tageslimit für diesen Agenten in US-Dollar. 0 = kein Limit. " + + "Bei Überschreitung wird kein Lauf mehr gestartet. " + + "Greift nur, wenn für das Modell Preise vorliegen — sonst zusätzlich " + + "das Token-Tageslimit setzen.")] + public decimal DailyBudgetUsd + { + get => _config.Budget.DailyCostUsd; + set => _config.Budget.DailyCostUsd = Math.Max(0, value); + } + + [Category("5 - Kosten")] + [DisplayName("Token-Tageslimit")] + [Description("Hartes Tageslimit für diesen Agenten in Tokens. 0 = kein Limit. " + + "Wirkt auch dann, wenn für das Modell keine Preise vorliegen.")] + public long DailyTokenLimit + { + get => _config.Budget.DailyTokens; + set => _config.Budget.DailyTokens = Math.Max(0, value); + } + // ──────────────── Tools (Read-Only) ──────────────── [Category("4 - Tools")] diff --git a/Program.cs b/Program.cs index 14281c4..9551f0b 100644 --- a/Program.cs +++ b/Program.cs @@ -8,6 +8,7 @@ using ClawdDotNet.Core.Tools; using ClawdDotNet.Core.State; using ClawdDotNet.Core.Storage; using ClawdDotNet.Core.Memory; +using ClawdDotNet.Core.Accounting; using ClawdDotNet.Services; using ClawdDotNet.Tools.FileRW; using ClawdDotNet.Tools.Telegram; @@ -158,9 +159,22 @@ internal static class Program var storage = new SqliteStorage(Path.Combine(instancePath, "state.db")); var stateStore = new SqliteStateStore(storage); var memoryRepository = new SqliteMemoryRepository(storage); + var usageRepository = new SqliteUsageRepository(storage); + + // Preise fürs Budget: Ohne sie greift nur die Token-Grenze. + var pricingCatalog = new ModelPricingCatalog(); + _ = Task.Run(async () => + { + try { pricingCatalog.Load(await openRouterClient.GetAvailableModelsAsync()); } + catch { /* Ohne Preise bleibt die Kostengrenze wirkungslos, die Token-Grenze nicht. */ } + }); agentEngine = new AgentEngine( - openRouterClient, toolRegistry, permissionGate, stateStore, loggerFactory, memoryRepository); + openRouterClient, toolRegistry, permissionGate, stateStore, loggerFactory, + memoryRepository, usageRepository, pricingCatalog) + { + InstanceBudget = instanceConfig.Budget + }; agentEngine.SetAgentConfigProvider( () => instanceConfig.Agents, instanceConfig.InstanceId, diff --git a/src/ClawdDotNet.Core/Accounting/SqliteUsageRepository.cs b/src/ClawdDotNet.Core/Accounting/SqliteUsageRepository.cs new file mode 100644 index 0000000..a63364d --- /dev/null +++ b/src/ClawdDotNet.Core/Accounting/SqliteUsageRepository.cs @@ -0,0 +1,163 @@ +using System.Globalization; +using ClawdDotNet.Core.Storage; +using Microsoft.Data.Sqlite; + +namespace ClawdDotNet.Core.Accounting; + +/// +/// Ablage der Verbrauchsdaten in der Instanz-Datenbank. +/// +/// Vorher lag das in TokenUsage.json: Bei JEDEM Agenten-Lauf wurde die gesamte Datei +/// geladen, ergänzt und neu geschrieben — unter einem globalen Lock. Das wächst +/// quadratisch und ist der eigentliche Engpass bei vielen Agenten, unabhängig davon, +/// welche Datenbank darunter liegt. +/// +/// Beträge werden als Text abgelegt und als decimal gelesen. SQLite kennt keinen +/// exakten Dezimaltyp; über REAL zu gehen würde bei Cent-Betraegen Rundungsfehler +/// einsammeln, die sich über tausende Läufe summieren. +/// +public sealed class SqliteUsageRepository : IUsageRepository +{ + private readonly SqliteStorage _storage; + + public SqliteUsageRepository(SqliteStorage storage) => _storage = storage; + + public Task RecordAsync(RunUsage usage, CancellationToken ct) + => _storage.WriteAsync(async conn => + { + var occurredAt = usage.OccurredAt == default ? DateTime.Now : usage.OccurredAt; + + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO RunUsage + (AgentId, Model, PromptTokens, CompletionTokens, CachedTokens, + CostUsd, CostIsKnown, Status, StepCount, DurationMs, OccurredAt, UsageDate) + VALUES + (@agentId, @model, @prompt, @completion, @cached, + @cost, @costKnown, @status, @steps, @duration, @occurredAt, @usageDate) + """; + + cmd.Parameters.AddWithValue("@agentId", usage.AgentId); + cmd.Parameters.AddWithValue("@model", usage.Model); + cmd.Parameters.AddWithValue("@prompt", usage.PromptTokens); + cmd.Parameters.AddWithValue("@completion", usage.CompletionTokens); + cmd.Parameters.AddWithValue("@cached", usage.CachedTokens); + cmd.Parameters.AddWithValue("@cost", FormatDecimal(usage.CostUsd)); + cmd.Parameters.AddWithValue("@costKnown", usage.CostIsKnown ? 1 : 0); + cmd.Parameters.AddWithValue("@status", usage.Status); + cmd.Parameters.AddWithValue("@steps", usage.StepCount); + cmd.Parameters.AddWithValue("@duration", usage.DurationMs); + cmd.Parameters.AddWithValue("@occurredAt", occurredAt.ToString("O")); + cmd.Parameters.AddWithValue("@usageDate", FormatDate(DateOnly.FromDateTime(occurredAt))); + + await cmd.ExecuteNonQueryAsync(ct); + }, ct); + + public async Task GetDailyAsync(DateOnly date, string agentId, CancellationToken ct) + { + await using var conn = await _storage.OpenConnectionAsync(ct); + using var cmd = conn.CreateCommand(); + + var filterAgent = !string.IsNullOrWhiteSpace(agentId); + + cmd.CommandText = $""" + SELECT COALESCE(SUM(PromptTokens), 0), + COALESCE(SUM(CompletionTokens), 0), + COUNT(*), + COALESCE(SUM(CASE WHEN CostIsKnown = 0 THEN 1 ELSE 0 END), 0) + FROM RunUsage + WHERE UsageDate = @date {(filterAgent ? "AND AgentId = @agentId" : "")} + """; + cmd.Parameters.AddWithValue("@date", FormatDate(date)); + if (filterAgent) + cmd.Parameters.AddWithValue("@agentId", agentId); + + int prompt = 0, completion = 0, runs = 0, unpriced = 0; + await using (var reader = await cmd.ExecuteReaderAsync(ct)) + { + if (await reader.ReadAsync(ct)) + { + prompt = reader.GetInt32(0); + completion = reader.GetInt32(1); + runs = reader.GetInt32(2); + unpriced = reader.GetInt32(3); + } + } + + // Beträge einzeln summieren, weil sie als Text abgelegt sind. + using var costCmd = conn.CreateCommand(); + costCmd.CommandText = $""" + SELECT CostUsd FROM RunUsage + WHERE UsageDate = @date {(filterAgent ? "AND AgentId = @agentId" : "")} + """; + costCmd.Parameters.AddWithValue("@date", FormatDate(date)); + if (filterAgent) + costCmd.Parameters.AddWithValue("@agentId", agentId); + + decimal cost = 0m; + await using (var reader = await costCmd.ExecuteReaderAsync(ct)) + { + while (await reader.ReadAsync(ct)) + cost += ParseDecimal(reader.GetString(0)); + } + + return new DailyUsage(date, prompt, completion, cost, unpriced == 0, runs); + } + + public async Task> GetRecentAsync(int limit, CancellationToken ct) + { + await using var conn = await _storage.OpenConnectionAsync(ct); + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT Id, AgentId, Model, PromptTokens, CompletionTokens, CachedTokens, + CostUsd, CostIsKnown, Status, StepCount, DurationMs, OccurredAt + FROM RunUsage + ORDER BY OccurredAt DESC + LIMIT @limit + """; + cmd.Parameters.AddWithValue("@limit", Math.Clamp(limit, 1, 1000)); + + var results = new List(); + await using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + results.Add(Read(reader)); + + return results; + } + + public Task PurgeBeforeAsync(DateOnly date, CancellationToken ct) + => _storage.WriteAsync(async conn => + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "DELETE FROM RunUsage WHERE UsageDate < @date"; + cmd.Parameters.AddWithValue("@date", FormatDate(date)); + return await cmd.ExecuteNonQueryAsync(ct); + }, ct); + + // ─── Hilfsfunktionen ─── + + private static RunUsage Read(SqliteDataReader reader) => new() + { + Id = reader.GetInt64(0), + AgentId = reader.GetString(1), + Model = reader.GetString(2), + PromptTokens = reader.GetInt32(3), + CompletionTokens = reader.GetInt32(4), + CachedTokens = reader.GetInt32(5), + CostUsd = ParseDecimal(reader.GetString(6)), + CostIsKnown = reader.GetInt32(7) != 0, + Status = reader.GetString(8), + StepCount = reader.GetInt32(9), + DurationMs = reader.GetInt64(10), + OccurredAt = DateTime.TryParse(reader.GetString(11), null, + DateTimeStyles.RoundtripKind, out var dt) ? dt : DateTime.MinValue + }; + + private static string FormatDate(DateOnly date) => date.ToString("yyyy-MM-dd"); + + private static string FormatDecimal(decimal value) + => value.ToString(CultureInfo.InvariantCulture); + + private static decimal ParseDecimal(string value) + => decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) ? d : 0m; +} diff --git a/src/ClawdDotNet.Core/Accounting/UsageModels.cs b/src/ClawdDotNet.Core/Accounting/UsageModels.cs new file mode 100644 index 0000000..b60a975 --- /dev/null +++ b/src/ClawdDotNet.Core/Accounting/UsageModels.cs @@ -0,0 +1,58 @@ +namespace ClawdDotNet.Core.Accounting; + +/// Verbrauch eines abgeschlossenen Laufs. +public sealed record RunUsage +{ + public long Id { get; init; } + public string AgentId { get; init; } = ""; + public string Model { get; init; } = ""; + + public int PromptTokens { get; init; } + public int CompletionTokens { get; init; } + + /// Anteil der Eingabe-Tokens aus dem Prompt-Cache — in PromptTokens enthalten. + public int CachedTokens { get; init; } + + public int TotalTokens => PromptTokens + CompletionTokens; + + public decimal CostUsd { get; init; } + + /// + /// Ob für das Modell Preise vorlagen. Ohne diese Angabe wäre eine zu niedrige + /// Summe von einer vollständigen nicht zu unterscheiden. + /// + public bool CostIsKnown { get; init; } + + public string Status { get; init; } = ""; + public int StepCount { get; init; } + public long DurationMs { get; init; } + + public DateTime OccurredAt { get; init; } +} + +/// Verbrauch eines Tages, aggregiert. +public sealed record DailyUsage( + DateOnly Date, + int PromptTokens, + int CompletionTokens, + decimal CostUsd, + bool CostIsComplete, + int RunCount) +{ + public int TotalTokens => PromptTokens + CompletionTokens; + + public static DailyUsage Empty(DateOnly date) => new(date, 0, 0, 0m, true, 0); +} + +public interface IUsageRepository +{ + Task RecordAsync(RunUsage usage, CancellationToken ct); + + /// Tagesverbrauch eines Agenten. leer = ganze Instanz. + Task GetDailyAsync(DateOnly date, string agentId, CancellationToken ct); + + Task> GetRecentAsync(int limit, CancellationToken ct); + + /// Entfernt Einträge älter als das angegebene Datum. Gibt die Anzahl zurück. + Task PurgeBeforeAsync(DateOnly date, CancellationToken ct); +} diff --git a/src/ClawdDotNet.Core/Budget/BudgetGuard.cs b/src/ClawdDotNet.Core/Budget/BudgetGuard.cs new file mode 100644 index 0000000..2ecac6b --- /dev/null +++ b/src/ClawdDotNet.Core/Budget/BudgetGuard.cs @@ -0,0 +1,121 @@ +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Accounting; + +namespace ClawdDotNet.Core.Budget; + +/// Welche Grenze überschritten wurde. +public enum BudgetLimit +{ + None, + AgentCost, + AgentTokens, + InstanceCost, + InstanceTokens +} + +public sealed record BudgetStatus(bool IsAllowed, BudgetLimit Limit, string? Reason) +{ + public static BudgetStatus Allowed { get; } = new(true, BudgetLimit.None, null); +} + +/// +/// Wacht über das Tagesbudget. +/// +/// Hintergrund (K5): 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. +/// +/// Zwei Arten von Grenzen, weil sich Kosten nicht immer beziffern lassen: Liefert der +/// Anbieter für ein Modell keine Preise, greift die Kostengrenze nicht — die +/// Token-Grenze dagegen immer. Wer sich absichern will, setzt beide. +/// +public sealed class BudgetGuard +{ + private readonly IUsageRepository _usage; + private readonly Func _now; + + public BudgetGuard(IUsageRepository usage, Func? now = null) + { + _usage = usage; + _now = now ?? (() => DateTime.Now); + } + + /// + /// Prüft, ob der Agent heute noch laufen darf. + /// + public async Task CheckAsync( + AgentConfig agent, InstanceBudget instanceBudget, CancellationToken ct) + { + var today = DateOnly.FromDateTime(_now()); + + // Agentengrenze + var agentBudget = agent.Budget; + if (agentBudget.HasAnyLimit) + { + var used = await _usage.GetDailyAsync(today, agent.AgentId, ct); + + if (Exceeds(agentBudget.DailyCostUsd, used.CostUsd)) + { + return new BudgetStatus(false, BudgetLimit.AgentCost, + $"Tagesbudget des Agenten erschöpft: {used.CostUsd:F4} von maximal " + + $"{agentBudget.DailyCostUsd:F2} USD verbraucht." + + UnpricedHint(used)); + } + + if (Exceeds(agentBudget.DailyTokens, used.TotalTokens)) + { + return new BudgetStatus(false, BudgetLimit.AgentTokens, + $"Token-Tageslimit des Agenten erschöpft: {used.TotalTokens:N0} von maximal " + + $"{agentBudget.DailyTokens:N0}."); + } + } + + // Instanzgrenze — gilt für alle Agenten zusammen + if (instanceBudget.HasAnyLimit) + { + var used = await _usage.GetDailyAsync(today, agentId: "", ct); + + if (Exceeds(instanceBudget.DailyCostUsd, used.CostUsd)) + { + return new BudgetStatus(false, BudgetLimit.InstanceCost, + $"Tagesbudget der Instanz erschöpft: {used.CostUsd:F4} von maximal " + + $"{instanceBudget.DailyCostUsd:F2} USD verbraucht." + + UnpricedHint(used)); + } + + if (Exceeds(instanceBudget.DailyTokens, used.TotalTokens)) + { + return new BudgetStatus(false, BudgetLimit.InstanceTokens, + $"Token-Tageslimit der Instanz erschöpft: {used.TotalTokens:N0} von maximal " + + $"{instanceBudget.DailyTokens:N0}."); + } + } + + return BudgetStatus.Allowed; + } + + /// Verbleibendes Kostenbudget des Agenten für heute; null bei fehlender Grenze. + public async Task GetRemainingCostAsync(AgentConfig agent, CancellationToken ct) + { + if (agent.Budget.DailyCostUsd <= 0) + return null; + + var used = await _usage.GetDailyAsync(DateOnly.FromDateTime(_now()), agent.AgentId, ct); + return Math.Max(0m, agent.Budget.DailyCostUsd - used.CostUsd); + } + + /// 0 oder kleiner bedeutet: keine Grenze gesetzt. + private static bool Exceeds(decimal limit, decimal used) => limit > 0 && used >= limit; + + private static bool Exceeds(long limit, long used) => limit > 0 && used >= limit; + + /// + /// Ohne Preisangaben ist die verbrauchte Summe unvollständig — sonst wirkte ein + /// niedriger Wert wie ein noch offener Spielraum. + /// + private static string UnpricedHint(DailyUsage used) + => used.CostIsComplete + ? "" + : " Achtung: Für mindestens ein Modell lagen keine Preise vor, der tatsächliche " + + "Verbrauch liegt höher."; +} diff --git a/src/ClawdDotNet.Core/Config/AgentConfig.cs b/src/ClawdDotNet.Core/Config/AgentConfig.cs index 0365fd9..ef22d28 100644 --- a/src/ClawdDotNet.Core/Config/AgentConfig.cs +++ b/src/ClawdDotNet.Core/Config/AgentConfig.cs @@ -106,6 +106,42 @@ public sealed class AgentConfig /// [JsonPropertyName("maxToolResultChars")] public int MaxToolResultChars { get; set; } = 16_000; + + [JsonPropertyName("budget")] + public AgentBudget Budget { get; set; } = new(); +} + +/// +/// Tagesgrenzen eines Agenten. 0 bedeutet jeweils: keine Grenze. +/// +/// Zwei Arten, weil sich Kosten nicht immer beziffern lassen: Liefert der Anbieter für +/// ein Modell keine Preise, greift die Kostengrenze nicht — die Token-Grenze immer. +/// +public sealed class AgentBudget +{ + [JsonPropertyName("dailyCostUsd")] + public decimal DailyCostUsd { get; set; } + + [JsonPropertyName("dailyTokens")] + public long DailyTokens { get; set; } + + [JsonIgnore] + public bool HasAnyLimit => DailyCostUsd > 0 || DailyTokens > 0; +} + +/// Tagesgrenzen über alle Agenten einer Instanz hinweg. +public sealed class InstanceBudget +{ + [JsonPropertyName("dailyCostUsd")] + public decimal DailyCostUsd { get; set; } + + [JsonPropertyName("dailyTokens")] + public long DailyTokens { get; set; } + + [JsonIgnore] + public bool HasAnyLimit => DailyCostUsd > 0 || DailyTokens > 0; + + public static InstanceBudget Unlimited { get; } = new(); } public sealed class SchedulerConfig diff --git a/src/ClawdDotNet.Core/Config/InstanceConfig.cs b/src/ClawdDotNet.Core/Config/InstanceConfig.cs index fa1191b..47ef240 100644 --- a/src/ClawdDotNet.Core/Config/InstanceConfig.cs +++ b/src/ClawdDotNet.Core/Config/InstanceConfig.cs @@ -25,6 +25,10 @@ public sealed class InstanceConfig [JsonPropertyName("telegramClient")] public TelegramClientConfig? TelegramClient { get; set; } + /// Tagesgrenzen über alle Agenten der Instanz hinweg. 0 = keine Grenze. + [JsonPropertyName("budget")] + public InstanceBudget Budget { get; set; } = new(); + [JsonPropertyName("agents")] public List Agents { get; set; } = new(); diff --git a/src/ClawdDotNet.Core/Engine/AgentEngine.cs b/src/ClawdDotNet.Core/Engine/AgentEngine.cs index c5eaefa..74926b4 100644 --- a/src/ClawdDotNet.Core/Engine/AgentEngine.cs +++ b/src/ClawdDotNet.Core/Engine/AgentEngine.cs @@ -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; + + /// Tagesgrenzen der Instanz. Wird vom Host gesetzt. + public InstanceBudget InstanceBudget { get; set; } = InstanceBudget.Unlimited; private readonly ContextCompactor _compactor; private readonly Dictionary> _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 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 ─── + + /// + /// Prüft das Tagesbudget. Gibt ein Ergebnis zurück, wenn der Lauf nicht stattfinden + /// darf — sonst null. + /// + private async Task 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; + } + + /// + /// Schreibt den Verbrauch eines Laufs fort. Fehler hierbei dürfen den Lauf nicht + /// nachträglich scheitern lassen — die eigentliche Arbeit ist bereits getan. + /// + 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); + } + } + /// Sammelt die Token-Zahlen über alle Schritte eines Runs. 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; diff --git a/src/ClawdDotNet.Core/Engine/AgentRunResult.cs b/src/ClawdDotNet.Core/Engine/AgentRunResult.cs index 42978bb..fdae986 100644 --- a/src/ClawdDotNet.Core/Engine/AgentRunResult.cs +++ b/src/ClawdDotNet.Core/Engine/AgentRunResult.cs @@ -28,5 +28,8 @@ public enum AgentRunStatus Completed, Failed, Cancelled, - LoopLimitExceeded + LoopLimitExceeded, + + /// Der Lauf fand nicht statt, weil das Tagesbudget erschöpft ist. + BudgetExceeded } diff --git a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs index 22209e5..b0bbacc 100644 --- a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs +++ b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs @@ -128,6 +128,30 @@ public sealed class SqliteStorage CREATE UNIQUE INDEX IF NOT EXISTS UX_Memories_Key ON Memories (Scope, OwnerId, MemoryKey) WHERE MemoryKey IS NOT NULL; + + CREATE TABLE IF NOT EXISTS RunUsage ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + AgentId TEXT NOT NULL, + Model TEXT NOT NULL, + PromptTokens INTEGER NOT NULL, + CompletionTokens INTEGER NOT NULL, + CachedTokens INTEGER NOT NULL DEFAULT 0, + CostUsd TEXT NOT NULL DEFAULT '0', + CostIsKnown INTEGER NOT NULL DEFAULT 0, + Status TEXT NOT NULL DEFAULT '', + StepCount INTEGER NOT NULL DEFAULT 0, + DurationMs INTEGER NOT NULL DEFAULT 0, + OccurredAt TEXT NOT NULL, + -- Ortsdatum, damit ein Tagesbudget der Wahrnehmung des Benutzers folgt + -- und die Abfrage ohne Zeitzonenrechnerei auskommt. + UsageDate TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS IX_RunUsage_Day + ON RunUsage (UsageDate, AgentId); + + CREATE INDEX IF NOT EXISTS IX_RunUsage_Recent + ON RunUsage (OccurredAt DESC); """; cmd.ExecuteNonQuery(); diff --git a/tests/ClawdDotNet.Core.Tests/Budget/BudgetGuardTests.cs b/tests/ClawdDotNet.Core.Tests/Budget/BudgetGuardTests.cs new file mode 100644 index 0000000..3aebd93 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Budget/BudgetGuardTests.cs @@ -0,0 +1,325 @@ +using ClawdDotNet.Core.Accounting; +using ClawdDotNet.Core.Budget; +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Storage; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Budget; + +/// +/// K5 aus der Bestandsaufnahme: 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. +/// +public sealed class BudgetGuardTests : IDisposable +{ + private readonly string _directory; + private readonly SqliteUsageRepository _usage; + private readonly DateTime _heute = new(2026, 7, 28, 14, 0, 0, DateTimeKind.Local); + + public BudgetGuardTests() + { + _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); + _usage = new SqliteUsageRepository(new SqliteStorage(Path.Combine(_directory, "state.db"))); + } + + public void Dispose() + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + try { Directory.Delete(_directory, recursive: true); } + catch { /* Aufräumen ist Nebensache */ } + } + + private BudgetGuard Guard() => new(_usage, () => _heute); + + private static AgentConfig Agent(decimal costLimit = 0, long tokenLimit = 0, string id = "agent-a") => new() + { + AgentId = id, + Budget = new AgentBudget { DailyCostUsd = costLimit, DailyTokens = tokenLimit } + }; + + private Task Record(decimal cost, int prompt = 1000, int completion = 100, + string agentId = "agent-a", bool costKnown = true, DateTime? when = null) + => _usage.RecordAsync(new RunUsage + { + AgentId = agentId, + Model = "test/model", + PromptTokens = prompt, + CompletionTokens = completion, + CostUsd = cost, + CostIsKnown = costKnown, + OccurredAt = when ?? _heute + }, default); + + // ═══════════════════════════════════════════════════════════ + // Ohne Grenzen + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Ohne_gesetzte_Grenze_darf_immer_gelaufen_werden() + { + await Record(cost: 1_000m); + + var status = await Guard().CheckAsync(Agent(), InstanceBudget.Unlimited, default); + + status.IsAllowed.ShouldBeTrue(); + } + + // ═══════════════════════════════════════════════════════════ + // Kostengrenze + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Unterhalb_der_Kostengrenze_laeuft_der_Agent_weiter() + { + await Record(cost: 1.50m); + + var status = await Guard().CheckAsync(Agent(costLimit: 5m), InstanceBudget.Unlimited, default); + + status.IsAllowed.ShouldBeTrue(); + } + + [Fact] + public async Task Bei_erreichter_Kostengrenze_wird_kein_Lauf_mehr_gestartet() + { + await Record(cost: 3m); + await Record(cost: 2m); + + var status = await Guard().CheckAsync(Agent(costLimit: 5m), InstanceBudget.Unlimited, default); + + status.IsAllowed.ShouldBeFalse(); + status.Limit.ShouldBe(BudgetLimit.AgentCost); + status.Reason.ShouldContain("5"); + } + + // ═══════════════════════════════════════════════════════════ + // Token-Grenze — greift auch ohne Preisangaben + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Die_Tokengrenze_wirkt_auch_wenn_keine_Preise_vorliegen() + { + // Der eigentliche Zweck der zweiten Grenze: Bei unbekanntem Modell bleibt die + // Kostensumme 0 und eine reine Kostengrenze wäre wirkungslos. + await Record(cost: 0m, prompt: 60_000, completion: 5_000, costKnown: false); + + var agent = Agent(costLimit: 10m, tokenLimit: 50_000); + var status = await Guard().CheckAsync(agent, InstanceBudget.Unlimited, default); + + status.IsAllowed.ShouldBeFalse(); + status.Limit.ShouldBe(BudgetLimit.AgentTokens); + } + + [Fact] + public async Task Fehlende_Preise_werden_in_der_Begruendung_erwaehnt() + { + // Sonst wirkte ein niedriger Verbrauch wie ein noch offener Spielraum. + await Record(cost: 0m, prompt: 10_000, completion: 1_000, costKnown: false); + await Record(cost: 5m); + + var status = await Guard().CheckAsync(Agent(costLimit: 5m), InstanceBudget.Unlimited, default); + + status.IsAllowed.ShouldBeFalse(); + status.Reason.ShouldContain("keine Preise"); + } + + // ═══════════════════════════════════════════════════════════ + // Instanzgrenze + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Die_Instanzgrenze_zaehlt_alle_Agenten_zusammen() + { + await Record(cost: 3m, agentId: "agent-a"); + await Record(cost: 3m, agentId: "agent-b"); + await Record(cost: 3m, agentId: "agent-c"); + + var instance = new InstanceBudget { DailyCostUsd = 8m }; + var status = await Guard().CheckAsync(Agent(), instance, default); + + status.IsAllowed.ShouldBeFalse(); + status.Limit.ShouldBe(BudgetLimit.InstanceCost); + } + + [Fact] + public async Task Ein_einzelner_Agent_bleibt_unter_der_Instanzgrenze_erlaubt() + { + await Record(cost: 2m, agentId: "agent-a"); + + var instance = new InstanceBudget { DailyCostUsd = 10m }; + var status = await Guard().CheckAsync(Agent(costLimit: 5m), instance, default); + + status.IsAllowed.ShouldBeTrue(); + } + + // ═══════════════════════════════════════════════════════════ + // Tageswechsel + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Der_Verbrauch_des_Vortages_zaehlt_nicht_mit() + { + await Record(cost: 100m, when: _heute.AddDays(-1)); + + var status = await Guard().CheckAsync(Agent(costLimit: 5m), InstanceBudget.Unlimited, default); + + status.IsAllowed.ShouldBeTrue(); + } + + [Fact] + public async Task Am_naechsten_Tag_steht_das_Budget_wieder_zur_Verfuegung() + { + await Record(cost: 10m); + + var morgen = new BudgetGuard(_usage, () => _heute.AddDays(1)); + var status = await morgen.CheckAsync(Agent(costLimit: 5m), InstanceBudget.Unlimited, default); + + status.IsAllowed.ShouldBeTrue(); + } + + // ═══════════════════════════════════════════════════════════ + // Restbudget + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Das_Restbudget_wird_korrekt_berechnet() + { + await Record(cost: 1.25m); + + var remaining = await Guard().GetRemainingCostAsync(Agent(costLimit: 5m), default); + + remaining.ShouldBe(3.75m); + } + + [Fact] + public async Task Ohne_Kostengrenze_gibt_es_kein_Restbudget() + { + (await Guard().GetRemainingCostAsync(Agent(), default)).ShouldBeNull(); + } + + [Fact] + public async Task Das_Restbudget_wird_nicht_negativ() + { + await Record(cost: 20m); + + (await Guard().GetRemainingCostAsync(Agent(costLimit: 5m), default)).ShouldBe(0m); + } +} + +public sealed class UsageRepositoryTests : IDisposable +{ + private readonly string _directory; + private readonly SqliteUsageRepository _usage; + private static readonly DateTime Tag = new(2026, 7, 28, 10, 0, 0, DateTimeKind.Local); + + public UsageRepositoryTests() + { + _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); + _usage = new SqliteUsageRepository(new SqliteStorage(Path.Combine(_directory, "state.db"))); + } + + public void Dispose() + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + try { Directory.Delete(_directory, recursive: true); } + catch { } + } + + private Task Record(decimal cost, string agentId = "a", DateTime? when = null, bool known = true) + => _usage.RecordAsync(new RunUsage + { + AgentId = agentId, + Model = "test/model", + PromptTokens = 1000, + CompletionTokens = 200, + CostUsd = cost, + CostIsKnown = known, + Status = "Completed", + OccurredAt = when ?? Tag + }, default); + + [Fact] + public async Task Betraege_ueberstehen_den_Rundlauf_ohne_Rundungsfehler() + { + // Als Fliesskommazahl abgelegt wuerden sich Cent-Betraege ueber tausende + // Laeufe zu einem sichtbaren Fehler summieren. + for (var i = 0; i < 1000; i++) + await Record(0.0001m); + + var daily = await _usage.GetDailyAsync(DateOnly.FromDateTime(Tag), "a", default); + + daily.CostUsd.ShouldBe(0.1m); + } + + [Fact] + public async Task Der_Tagesverbrauch_summiert_Tokens_und_Laeufe() + { + await Record(1m); + await Record(2m); + + var daily = await _usage.GetDailyAsync(DateOnly.FromDateTime(Tag), "a", default); + + daily.RunCount.ShouldBe(2); + daily.PromptTokens.ShouldBe(2000); + daily.CompletionTokens.ShouldBe(400); + daily.TotalTokens.ShouldBe(2400); + } + + [Fact] + public async Task Ohne_Agentenfilter_zaehlt_die_ganze_Instanz() + { + await Record(1m, agentId: "a"); + await Record(2m, agentId: "b"); + + var daily = await _usage.GetDailyAsync(DateOnly.FromDateTime(Tag), agentId: "", default); + + daily.CostUsd.ShouldBe(3m); + daily.RunCount.ShouldBe(2); + } + + [Fact] + public async Task Ein_Lauf_ohne_Preisangabe_macht_den_Tag_unvollstaendig() + { + await Record(1m); + await Record(0m, known: false); + + var daily = await _usage.GetDailyAsync(DateOnly.FromDateTime(Tag), "a", default); + + daily.CostIsComplete.ShouldBeFalse(); + } + + [Fact] + public async Task Ein_Tag_ohne_Laeufe_ist_leer() + { + var daily = await _usage.GetDailyAsync(DateOnly.FromDateTime(Tag), "a", default); + + daily.RunCount.ShouldBe(0); + daily.CostUsd.ShouldBe(0m); + daily.CostIsComplete.ShouldBeTrue(); + } + + [Fact] + public async Task Alte_Eintraege_lassen_sich_entfernen() + { + await Record(1m, when: Tag.AddDays(-40)); + await Record(2m, when: Tag); + + var removed = await _usage.PurgeBeforeAsync(DateOnly.FromDateTime(Tag.AddDays(-30)), default); + + removed.ShouldBe(1); + (await _usage.GetRecentAsync(10, default)).Count.ShouldBe(1); + } + + [Fact] + public async Task Viele_Laeufe_koennen_gleichzeitig_erfasst_werden() + { + // Der alte Weg schrieb TokenUsage.json bei jedem Lauf komplett neu, unter einem + // globalen Lock — genau das skalierte nicht. + await Task.WhenAll(Enumerable.Range(0, 100).Select(i => Record(0.01m, agentId: $"agent-{i % 8}"))); + + var daily = await _usage.GetDailyAsync(DateOnly.FromDateTime(Tag), agentId: "", default); + + daily.RunCount.ShouldBe(100); + daily.CostUsd.ShouldBe(1.00m); + } +}