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
@@ -0,0 +1,163 @@
|
||||
using System.Globalization;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace ClawdDotNet.Core.Accounting;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<DailyUsage> 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<IReadOnlyList<RunUsage>> 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<RunUsage>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
results.Add(Read(reader));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public Task<int> 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;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
namespace ClawdDotNet.Core.Accounting;
|
||||
|
||||
/// <summary>Verbrauch eines abgeschlossenen Laufs.</summary>
|
||||
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; }
|
||||
|
||||
/// <summary>Anteil der Eingabe-Tokens aus dem Prompt-Cache — in PromptTokens enthalten.</summary>
|
||||
public int CachedTokens { get; init; }
|
||||
|
||||
public int TotalTokens => PromptTokens + CompletionTokens;
|
||||
|
||||
public decimal CostUsd { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ob für das Modell Preise vorlagen. Ohne diese Angabe wäre eine zu niedrige
|
||||
/// Summe von einer vollständigen nicht zu unterscheiden.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>Verbrauch eines Tages, aggregiert.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>Tagesverbrauch eines Agenten. <paramref name="agentId"/> leer = ganze Instanz.</summary>
|
||||
Task<DailyUsage> GetDailyAsync(DateOnly date, string agentId, CancellationToken ct);
|
||||
|
||||
Task<IReadOnlyList<RunUsage>> GetRecentAsync(int limit, CancellationToken ct);
|
||||
|
||||
/// <summary>Entfernt Einträge älter als das angegebene Datum. Gibt die Anzahl zurück.</summary>
|
||||
Task<int> PurgeBeforeAsync(DateOnly date, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Accounting;
|
||||
|
||||
namespace ClawdDotNet.Core.Budget;
|
||||
|
||||
/// <summary>Welche Grenze überschritten wurde.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class BudgetGuard
|
||||
{
|
||||
private readonly IUsageRepository _usage;
|
||||
private readonly Func<DateTime> _now;
|
||||
|
||||
public BudgetGuard(IUsageRepository usage, Func<DateTime>? now = null)
|
||||
{
|
||||
_usage = usage;
|
||||
_now = now ?? (() => DateTime.Now);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft, ob der Agent heute noch laufen darf.
|
||||
/// </summary>
|
||||
public async Task<BudgetStatus> 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;
|
||||
}
|
||||
|
||||
/// <summary>Verbleibendes Kostenbudget des Agenten für heute; null bei fehlender Grenze.</summary>
|
||||
public async Task<decimal?> 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);
|
||||
}
|
||||
|
||||
/// <summary>0 oder kleiner bedeutet: keine Grenze gesetzt.</summary>
|
||||
private static bool Exceeds(decimal limit, decimal used) => limit > 0 && used >= limit;
|
||||
|
||||
private static bool Exceeds(long limit, long used) => limit > 0 && used >= limit;
|
||||
|
||||
/// <summary>
|
||||
/// Ohne Preisangaben ist die verbrauchte Summe unvollständig — sonst wirkte ein
|
||||
/// niedriger Wert wie ein noch offener Spielraum.
|
||||
/// </summary>
|
||||
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.";
|
||||
}
|
||||
@@ -106,6 +106,42 @@ public sealed class AgentConfig
|
||||
/// </summary>
|
||||
[JsonPropertyName("maxToolResultChars")]
|
||||
public int MaxToolResultChars { get; set; } = 16_000;
|
||||
|
||||
[JsonPropertyName("budget")]
|
||||
public AgentBudget Budget { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Tagesgrenzen über alle Agenten einer Instanz hinweg.</summary>
|
||||
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
|
||||
|
||||
@@ -25,6 +25,10 @@ public sealed class InstanceConfig
|
||||
[JsonPropertyName("telegramClient")]
|
||||
public TelegramClientConfig? TelegramClient { get; set; }
|
||||
|
||||
/// <summary>Tagesgrenzen über alle Agenten der Instanz hinweg. 0 = keine Grenze.</summary>
|
||||
[JsonPropertyName("budget")]
|
||||
public InstanceBudget Budget { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("agents")]
|
||||
public List<AgentConfig> Agents { get; set; } = new();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -28,5 +28,8 @@ public enum AgentRunStatus
|
||||
Completed,
|
||||
Failed,
|
||||
Cancelled,
|
||||
LoopLimitExceeded
|
||||
LoopLimitExceeded,
|
||||
|
||||
/// <summary>Der Lauf fand nicht statt, weil das Tagesbudget erschöpft ist.</summary>
|
||||
BudgetExceeded
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user