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);
+ }
+}