#608: Ein wegen erschoepften Budgets abgelehnter Lauf hinterlaesst jetzt einen Beleg (Status BudgetExceeded) an Aufgabe und Verlauf; die Log-Warnung kommt einmal je Agent, Grenze und Tag. Die Kostenprognose zaehlt Ablehnungen nicht als Lauf und warnt, wenn die geplanten Aufgaben eines Agenten sein Tagesbudget uebersteigen (API + Verbrauchsansicht). #607: Passt schon der erste Treffer nicht in den Abruf, wird er gekuerzt (Anfang und Ende bleiben) statt ganz zu fehlen. remember weist auf gleichnamige Eintraege ohne Schluessel und auf Eintraege nahe der Abrufgrenze hin. Version 0.1.9. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,7 +18,7 @@
|
|||||||
ClawdDotNet.Core.BuildInfo bleibt daneben bestehen: das ist ein von Hand
|
ClawdDotNet.Core.BuildInfo bleibt daneben bestehen: das ist ein von Hand
|
||||||
gefuehrter Zaehler mit Aenderungstext, keine Versionsangabe.
|
gefuehrter Zaehler mit Aenderungstext, keine Versionsangabe.
|
||||||
-->
|
-->
|
||||||
<Version>0.1.8</Version>
|
<Version>0.1.9</Version>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Symbole in die Assembly einbetten statt als .pdb daneben zu legen.
|
Symbole in die Assembly einbetten statt als .pdb daneben zu legen.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using ClawdDotNet.Core.Api;
|
using ClawdDotNet.Core.Api;
|
||||||
using ClawdDotNet.Core.Audit;
|
using ClawdDotNet.Core.Audit;
|
||||||
using ClawdDotNet.Core.Config;
|
using ClawdDotNet.Core.Config;
|
||||||
|
using ClawdDotNet.Core.Engine;
|
||||||
using ClawdDotNet.Core.Tasks;
|
using ClawdDotNet.Core.Tasks;
|
||||||
|
|
||||||
namespace ClawdDotNet.Core.Accounting;
|
namespace ClawdDotNet.Core.Accounting;
|
||||||
@@ -34,6 +35,12 @@ public sealed record AufgabenPrognose(
|
|||||||
int Stichprobe,
|
int Stichprobe,
|
||||||
decimal KostenProTag);
|
decimal KostenProTag);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ein Agent, dessen Aufgaben laut Prognose mehr kosten, als sein Tagesbudget erlaubt (#608).
|
||||||
|
/// Die spaeten Laeufe eines solchen Tages lehnt die Engine ab.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record BudgetWarnung(string AgentId, decimal KostenProTag, decimal BudgetProTag);
|
||||||
|
|
||||||
/// <summary>Die Prognose der ganzen Instanz.</summary>
|
/// <summary>Die Prognose der ganzen Instanz.</summary>
|
||||||
public sealed record InstanzPrognose(
|
public sealed record InstanzPrognose(
|
||||||
IReadOnlyList<AufgabenPrognose> Aufgaben,
|
IReadOnlyList<AufgabenPrognose> Aufgaben,
|
||||||
@@ -42,7 +49,8 @@ public sealed record InstanzPrognose(
|
|||||||
decimal SummeProTag,
|
decimal SummeProTag,
|
||||||
decimal SummeProMonat,
|
decimal SummeProMonat,
|
||||||
decimal EinmaligOffen,
|
decimal EinmaligOffen,
|
||||||
int OhneKostenbasis);
|
int OhneKostenbasis,
|
||||||
|
IReadOnlyList<BudgetWarnung> Budgetwarnungen);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Rechnet aus Terminen und bisherigen Laeufen hoch, was eine Instanz kuenftig kostet (#595).
|
/// Rechnet aus Terminen und bisherigen Laeufen hoch, was eine Instanz kuenftig kostet (#595).
|
||||||
@@ -81,6 +89,11 @@ public static class KostenPrognose
|
|||||||
jetztUtc = DateTime.SpecifyKind(jetztUtc, DateTimeKind.Utc);
|
jetztUtc = DateTime.SpecifyKind(jetztUtc, DateTimeKind.Utc);
|
||||||
var seit = jetztUtc.AddDays(-TageRueckblick);
|
var seit = jetztUtc.AddDays(-TageRueckblick);
|
||||||
|
|
||||||
|
// #608: Eine Ablehnung wegen erschoepften Budgets hinterlaesst einen Beleg ueber 0 USD.
|
||||||
|
// Sie ist kein Lauf und darf den Schnitt nicht druecken.
|
||||||
|
belegeJeAufgabe = belegeJeAufgabe.ToDictionary(
|
||||||
|
e => e.Key, e => (IReadOnlyList<RunReceipt>)e.Value.Where(IstLauf).ToList());
|
||||||
|
|
||||||
var agentNachId = agenten
|
var agentNachId = agenten
|
||||||
.GroupBy(a => a.AgentId, StringComparer.OrdinalIgnoreCase)
|
.GroupBy(a => a.AgentId, StringComparer.OrdinalIgnoreCase)
|
||||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||||
@@ -139,9 +152,26 @@ public static class KostenPrognose
|
|||||||
Runde(summeProTag),
|
Runde(summeProTag),
|
||||||
Runde(summeProTag * TageJeMonat),
|
Runde(summeProTag * TageJeMonat),
|
||||||
Runde(zeilen.Where(z => z.EinmaligOffen).Sum(z => z.KostenJeLauf ?? 0m)),
|
Runde(zeilen.Where(z => z.EinmaligOffen).Sum(z => z.KostenJeLauf ?? 0m)),
|
||||||
zeilen.Count(z => z.Grundlage == PrognoseGrundlage.Unbekannt && (z.LaeufeProTag > 0 || z.EinmaligOffen)));
|
zeilen.Count(z => z.Grundlage == PrognoseGrundlage.Unbekannt && (z.LaeufeProTag > 0 || z.EinmaligOffen)),
|
||||||
|
Budgetwarnungen(zeilen, agentNachId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #608: Reicht das Tagesbudget eines Agenten fuer seine geplanten Aufgaben nicht, soll das
|
||||||
|
/// vorher auffallen - nicht erst am Abend, wenn die ersten Laeufe abgelehnt werden.
|
||||||
|
/// Gezaehlt werden nur die Aufgaben; sonstige Laeufe lassen sich keinem Agenten sicher
|
||||||
|
/// zuordnen und kaemen noch hinzu.
|
||||||
|
/// </summary>
|
||||||
|
private static List<BudgetWarnung> Budgetwarnungen(
|
||||||
|
IEnumerable<AufgabenPrognose> zeilen, IReadOnlyDictionary<string, AgentConfig> agentNachId)
|
||||||
|
=> zeilen
|
||||||
|
.GroupBy(z => z.AgentId, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(g => (Agent: agentNachId.GetValueOrDefault(g.Key), Summe: g.Sum(z => z.KostenProTag)))
|
||||||
|
.Where(x => x.Agent is { Budget.DailyCostUsd: > 0 } a && x.Summe > a.Budget.DailyCostUsd)
|
||||||
|
.Select(x => new BudgetWarnung(x.Agent!.AgentId, Runde(x.Summe), x.Agent.Budget.DailyCostUsd))
|
||||||
|
.OrderByDescending(w => w.KostenProTag - w.BudgetProTag)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
private static AufgabenPrognose Arbeit(
|
private static AufgabenPrognose Arbeit(
|
||||||
TaskItem aufgabe, string agentId, AgentConfig? agent, IReadOnlyList<RunReceipt> belege,
|
TaskItem aufgabe, string agentId, AgentConfig? agent, IReadOnlyList<RunReceipt> belege,
|
||||||
IReadOnlyDictionary<string, List<RunReceipt>> bekannteJeAgent,
|
IReadOnlyDictionary<string, List<RunReceipt>> bekannteJeAgent,
|
||||||
@@ -215,6 +245,9 @@ public static class KostenPrognose
|
|||||||
return jeSchritt * AnnahmeSchritte;
|
return jeSchritt * AnnahmeSchritte;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IstLauf(RunReceipt beleg)
|
||||||
|
=> beleg.Status != nameof(AgentRunStatus.BudgetExceeded);
|
||||||
|
|
||||||
private static DateTime Utc(DateTime wert)
|
private static DateTime Utc(DateTime wert)
|
||||||
=> wert.Kind == DateTimeKind.Utc ? wert : wert.ToUniversalTime();
|
=> wert.Kind == DateTimeKind.Utc ? wert : wert.ToUniversalTime();
|
||||||
|
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
|||||||
return zu;
|
return zu;
|
||||||
|
|
||||||
// Vor der ersten Anfrage prüfen — ein erschöpftes Budget soll gar nichts kosten.
|
// Vor der ersten Anfrage prüfen — ein erschöpftes Budget soll gar nichts kosten.
|
||||||
if (await CheckBudgetAsync(agentConfig, externalCt) is { } denied)
|
if (await CheckBudgetAsync(agentConfig, source ?? AuditSource.Direct, taskId, externalCt) is { } denied)
|
||||||
return denied;
|
return denied;
|
||||||
|
|
||||||
using var lauf = TrackRun();
|
using var lauf = TrackRun();
|
||||||
@@ -425,7 +425,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
|||||||
if (RefuseWhileShuttingDown(agentConfig) is { } zu)
|
if (RefuseWhileShuttingDown(agentConfig) is { } zu)
|
||||||
return zu;
|
return zu;
|
||||||
|
|
||||||
if (await CheckBudgetAsync(agentConfig, externalCt) is { } denied)
|
if (await CheckBudgetAsync(agentConfig, source, taskId, externalCt) is { } denied)
|
||||||
return denied;
|
return denied;
|
||||||
|
|
||||||
using var lauf = TrackRun();
|
using var lauf = TrackRun();
|
||||||
@@ -1600,7 +1600,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
|||||||
/// Prüft das Tagesbudget. Gibt ein Ergebnis zurück, wenn der Lauf nicht stattfinden
|
/// Prüft das Tagesbudget. Gibt ein Ergebnis zurück, wenn der Lauf nicht stattfinden
|
||||||
/// darf — sonst null.
|
/// darf — sonst null.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<AgentRunResult?> CheckBudgetAsync(AgentConfig agentConfig, CancellationToken ct)
|
private async Task<AgentRunResult?> CheckBudgetAsync(
|
||||||
|
AgentConfig agentConfig, string? source, string? taskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (_budgetGuard is null)
|
if (_budgetGuard is null)
|
||||||
return null;
|
return null;
|
||||||
@@ -1609,17 +1610,38 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
|||||||
if (status.IsAllowed)
|
if (status.IsAllowed)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
|
// #608: Ist ein Budget erschoepft, lehnt es jeden weiteren Lauf des Tages ab. Einmal je
|
||||||
|
// Agent, Grenze und Tag als Warnung genuegt - danach steht jede Ablehnung als Beleg da.
|
||||||
var logger = _loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Budget");
|
var logger = _loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Budget");
|
||||||
logger.LogWarning("Lauf abgelehnt für {AgentId}: {Reason}", agentConfig.AgentId, status.Reason);
|
var heute = DateOnly.FromDateTime(_clock.GetLocalNow().DateTime);
|
||||||
|
var schluessel = agentConfig.AgentId + "|" + status.Limit;
|
||||||
|
if (_budgetGewarnt.TryGetValue(schluessel, out var gewarnt) && gewarnt == heute)
|
||||||
|
{
|
||||||
|
logger.LogInformation("Lauf abgelehnt für {AgentId} (Aufgabe {TaskId}): {Reason}",
|
||||||
|
agentConfig.AgentId, taskId ?? "-", status.Reason);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_budgetGewarnt[schluessel] = heute;
|
||||||
|
logger.LogWarning("Lauf abgelehnt für {AgentId} (Aufgabe {TaskId}): {Reason} Weitere Ablehnungen heute stehen als Beleg im Verlauf.",
|
||||||
|
agentConfig.AgentId, taskId ?? "-", status.Reason);
|
||||||
|
}
|
||||||
|
|
||||||
var result = new AgentRunResult(
|
var result = new AgentRunResult(
|
||||||
agentConfig.AgentId, AgentRunStatus.BudgetExceeded,
|
agentConfig.AgentId, AgentRunStatus.BudgetExceeded,
|
||||||
$"[Budget erschöpft] {status.Reason}", 0, 0, TimeSpan.Zero);
|
$"[Budget erschöpft] {status.Reason}", 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
// #608: Ohne Beleg blieb die Ablehnung unsichtbar - Aufgabenkarte und Verlauf zeigten
|
||||||
|
// weiter den letzten erfolgreichen Lauf, die Luecke fiel nur im Log auf.
|
||||||
|
await RecordReceiptAsync(Guid.NewGuid().ToString("N"), agentConfig, result, source, taskId);
|
||||||
|
|
||||||
OnRunCompleted?.Invoke(agentConfig.Model, result);
|
OnRunCompleted?.Invoke(agentConfig.Model, result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Welche Budgetgrenze je Agent zuletzt an welchem Tag als Warnung gemeldet wurde.</summary>
|
||||||
|
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, DateOnly> _budgetGewarnt = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Schreibt den Verbrauch eines Laufs fort. Fehler hierbei dürfen den Lauf nicht
|
/// Schreibt den Verbrauch eines Laufs fort. Fehler hierbei dürfen den Lauf nicht
|
||||||
/// nachträglich scheitern lassen — die eigentliche Arbeit ist bereits getan.
|
/// nachträglich scheitern lassen — die eigentliche Arbeit ist bereits getan.
|
||||||
|
|||||||
@@ -190,6 +190,13 @@ async function erstellePrognoseKarte() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const hinweise = [];
|
const hinweise = [];
|
||||||
|
for (const w of p.budgetWarnings || []) {
|
||||||
|
hinweise.push(erstelleElement("div", {
|
||||||
|
klasse: "hinweis-box warnung",
|
||||||
|
text: `${w.agentId}: Die geplanten Aufgaben kosten laut Prognose ${formatiereKosten(w.costPerDay)} am Tag, `
|
||||||
|
+ `das Tagesbudget erlaubt ${formatiereKosten(w.budgetPerDay)}. Die späten Läufe eines Tages werden abgelehnt.`
|
||||||
|
}));
|
||||||
|
}
|
||||||
if (p.withoutBasis > 0) {
|
if (p.withoutBasis > 0) {
|
||||||
hinweise.push(erstelleElement("p", {
|
hinweise.push(erstelleElement("p", {
|
||||||
klasse: "gedaempft text-klein",
|
klasse: "gedaempft text-klein",
|
||||||
|
|||||||
@@ -165,7 +165,47 @@ public sealed class MemoryTool : IAgentTool
|
|||||||
|
|
||||||
return ToolResult.Ok(
|
return ToolResult.Ok(
|
||||||
$"Gemerkt (#{saved.Id}, {scopeLabel}){keyHint}\n" +
|
$"Gemerkt (#{saved.Id}, {scopeLabel}){keyHint}\n" +
|
||||||
$"Betreff: {saved.Subject} | Art: {saved.Category} | Wichtigkeit: {saved.Importance}");
|
$"Betreff: {saved.Subject} | Art: {saved.Category} | Wichtigkeit: {saved.Importance}" +
|
||||||
|
await HinweiseAsync(saved, scope, repository, context, ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #607: Ein Agent merkte sich bei jedem Lauf dieselbe, wachsende Liste ohne Schlüssel -
|
||||||
|
/// sieben Einträge mit zusammen 23.000 Zeichen, bis der neueste allein nicht mehr in einen
|
||||||
|
/// Abruf passte. Beides soll er erfahren, solange er es noch ändern kann.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<string> HinweiseAsync(
|
||||||
|
MemoryEntry saved, MemoryScope scope, IMemoryRepository repository, AgentToolContext context, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(saved.Key))
|
||||||
|
{
|
||||||
|
var gleicherBetreff = await repository.RecallAsync(new MemoryQuery
|
||||||
|
{
|
||||||
|
Scope = scope,
|
||||||
|
OwnerId = context.AgentId,
|
||||||
|
Subject = saved.Subject,
|
||||||
|
Limit = 50,
|
||||||
|
MinImportance = 1
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
var ohneSchluessel = gleicherBetreff.Count(e => string.IsNullOrWhiteSpace(e.Key));
|
||||||
|
if (ohneSchluessel > 1)
|
||||||
|
{
|
||||||
|
sb.Append($"\nHinweis: Unter dem Betreff '{saved.Subject}' liegen jetzt {ohneSchluessel} Einträge " +
|
||||||
|
"ohne Schlüssel. Ersetzt dieser Eintrag einen früheren, merke ihn mit 'key' – dann wird " +
|
||||||
|
"er fortgeschrieben statt verdoppelt. Überholte Einträge entfernst du mit forget (id).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saved.Content.Length > MaxResultChars / 2)
|
||||||
|
{
|
||||||
|
sb.Append($"\nHinweis: Der Eintrag ist {saved.Content.Length} Zeichen lang, ein Abruf zeigt höchstens " +
|
||||||
|
$"{MaxResultChars} Zeichen. Halte ihn kürzer oder teile ihn auf, sonst wird er beim Abruf gekürzt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<ToolResult> RecallAsync(
|
private static async Task<ToolResult> RecallAsync(
|
||||||
@@ -252,8 +292,21 @@ public sealed class MemoryTool : IAgentTool
|
|||||||
// Lieber weniger Treffer als ein gesprengter Kontext.
|
// Lieber weniger Treffer als ein gesprengter Kontext.
|
||||||
if (sb.Length + block.Length > MaxResultChars)
|
if (sb.Length + block.Length > MaxResultChars)
|
||||||
{
|
{
|
||||||
sb.AppendLine($"[… {entries.Count - shown} weitere Treffer ausgelassen. " +
|
// #607: Passte schon der erste Treffer nicht, fiel er frueher ganz weg - der
|
||||||
"Grenze die Suche ein, um sie zu sehen.]");
|
// Agent sah keinen einzigen Inhalt und hielt alles fuer neu. Jetzt wird er
|
||||||
|
// gekuerzt; Anfang und Ende bleiben stehen, weil wachsende Listen hinten
|
||||||
|
// anhaengen und Notizen vorne beginnen.
|
||||||
|
if (shown == 0)
|
||||||
|
{
|
||||||
|
sb.Append(FormatEntry(entry, scope, MaxResultChars - sb.Length - FooterReserve));
|
||||||
|
shown++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.Count > shown)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"[… {entries.Count - shown} weitere Treffer ausgelassen. " +
|
||||||
|
"Grenze die Suche ein, um sie zu sehen.]");
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,29 +317,56 @@ public sealed class MemoryTool : IAgentTool
|
|||||||
return sb.ToString().TrimEnd();
|
return sb.ToString().TrimEnd();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatEntry(MemoryEntry entry, MemoryScope scope)
|
/// <summary>Platz, der nach einem gekuerzten Treffer fuer den Auslassungshinweis bleibt.</summary>
|
||||||
|
private const int FooterReserve = 200;
|
||||||
|
|
||||||
|
private static string FormatEntry(MemoryEntry entry, MemoryScope scope, int? maxChars = null)
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
var header = new StringBuilder();
|
||||||
|
header.Append($"#{entry.Id} [{entry.Subject}] {entry.Category}, Wichtigkeit {entry.Importance}");
|
||||||
sb.Append($"#{entry.Id} [{entry.Subject}] {entry.Category}, Wichtigkeit {entry.Importance}");
|
|
||||||
if (!string.IsNullOrWhiteSpace(entry.Key))
|
if (!string.IsNullOrWhiteSpace(entry.Key))
|
||||||
sb.Append($", Schlüssel: {entry.Key}");
|
header.Append($", Schlüssel: {entry.Key}");
|
||||||
sb.AppendLine();
|
|
||||||
|
|
||||||
sb.AppendLine($" {entry.Content}");
|
|
||||||
|
|
||||||
var meta = new List<string> { $"aktualisiert {entry.UpdatedAt:yyyy-MM-dd HH:mm} UTC" };
|
var meta = new List<string> { $"aktualisiert {entry.UpdatedAt:yyyy-MM-dd HH:mm} UTC" };
|
||||||
if (entry.Tags.Count > 0)
|
if (entry.Tags.Count > 0)
|
||||||
meta.Add("Schlagworte: " + string.Join(", ", entry.Tags));
|
meta.Add("Schlagworte: " + string.Join(", ", entry.Tags));
|
||||||
if (scope == MemoryScope.Shared && !string.IsNullOrWhiteSpace(entry.CreatedBy))
|
if (scope == MemoryScope.Shared && !string.IsNullOrWhiteSpace(entry.CreatedBy))
|
||||||
meta.Add($"von {entry.CreatedBy}");
|
meta.Add($"von {entry.CreatedBy}");
|
||||||
|
var metaLine = $" ({string.Join(" | ", meta)})";
|
||||||
|
|
||||||
sb.AppendLine($" ({string.Join(" | ", meta)})");
|
var content = entry.Content;
|
||||||
|
if (maxChars is { } max)
|
||||||
|
content = Kuerze(content, max - header.Length - metaLine.Length - 16);
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append(header).AppendLine();
|
||||||
|
sb.AppendLine($" {content}");
|
||||||
|
sb.AppendLine(metaLine);
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
|
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Kuerzt einen Inhalt auf etwa <paramref name="platz"/> Zeichen, Anfang und Ende bleiben.</summary>
|
||||||
|
internal static string Kuerze(string content, int platz)
|
||||||
|
{
|
||||||
|
const int Mindestens = 400;
|
||||||
|
|
||||||
|
var marke = $"\n [… gekürzt: {{0}} von {content.Length} Zeichen ausgelassen – der Eintrag ist zu groß " +
|
||||||
|
"für einen Abruf. Teile ihn auf oder halte ihn kürzer.]\n ";
|
||||||
|
|
||||||
|
if (content.Length <= platz)
|
||||||
|
return content;
|
||||||
|
|
||||||
|
var haelfte = Math.Max(Mindestens, platz - marke.Length) / 2;
|
||||||
|
if (2 * haelfte >= content.Length)
|
||||||
|
return content;
|
||||||
|
|
||||||
|
return content[..haelfte]
|
||||||
|
+ string.Format(marke, content.Length - 2 * haelfte)
|
||||||
|
+ content[^haelfte..];
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Eingabe lesen ───
|
// ─── Eingabe lesen ───
|
||||||
|
|
||||||
private static string? GetString(JsonElement input, string name)
|
private static string? GetString(JsonElement input, string name)
|
||||||
|
|||||||
@@ -140,7 +140,11 @@ public sealed record ForecastDto(
|
|||||||
decimal TotalPerDay,
|
decimal TotalPerDay,
|
||||||
decimal TotalPerMonth,
|
decimal TotalPerMonth,
|
||||||
decimal OneOffOpen,
|
decimal OneOffOpen,
|
||||||
int WithoutBasis);
|
int WithoutBasis,
|
||||||
|
IReadOnlyList<ForecastBudgetDto> BudgetWarnings);
|
||||||
|
|
||||||
|
/// <summary>Ein Agent, dessen geplante Aufgaben laut Prognose sein Tagesbudget uebersteigen (#608).</summary>
|
||||||
|
public sealed record ForecastBudgetDto(string AgentId, decimal CostPerDay, decimal BudgetPerDay);
|
||||||
|
|
||||||
/// <summary>Eine Zeile der Kostenprognose. <c>Basis</c>: Laeufe, Agent, Schaetzung oder Unbekannt.</summary>
|
/// <summary>Eine Zeile der Kostenprognose. <c>Basis</c>: Laeufe, Agent, Schaetzung oder Unbekannt.</summary>
|
||||||
public sealed record ForecastTaskDto(
|
public sealed record ForecastTaskDto(
|
||||||
|
|||||||
@@ -100,7 +100,8 @@ internal static class Mapping
|
|||||||
prognose.SummeProTag,
|
prognose.SummeProTag,
|
||||||
prognose.SummeProMonat,
|
prognose.SummeProMonat,
|
||||||
prognose.EinmaligOffen,
|
prognose.EinmaligOffen,
|
||||||
prognose.OhneKostenbasis);
|
prognose.OhneKostenbasis,
|
||||||
|
prognose.Budgetwarnungen.Select(w => new ForecastBudgetDto(w.AgentId, w.KostenProTag, w.BudgetProTag)).ToList());
|
||||||
|
|
||||||
public static ServiceDto ToDto(this ServiceConfig service)
|
public static ServiceDto ToDto(this ServiceConfig service)
|
||||||
=> new(
|
=> new(
|
||||||
|
|||||||
@@ -113,6 +113,46 @@ public sealed class KostenPrognoseTests
|
|||||||
p.SummeProTag.ShouldBe(3.44m);
|
p.SummeProTag.ShouldBe(3.44m);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Budgetablehnungen_druecken_den_Schnitt_nicht()
|
||||||
|
{
|
||||||
|
// #608: Eine Ablehnung steht als Beleg ueber 0 USD im Verlauf - sie ist kein Lauf.
|
||||||
|
var abgelehnt = Beleg("t-1", 0m) with { Status = "BudgetExceeded" };
|
||||||
|
|
||||||
|
var zeile = Rechne([Stuendlich("t-1")], Belege(("t-1", [Beleg("t-1", 0.02m, 2), abgelehnt])))
|
||||||
|
.Aufgaben.Single();
|
||||||
|
|
||||||
|
zeile.KostenJeLauf.ShouldBe(0.02m);
|
||||||
|
zeile.Stichprobe.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Warnt_wenn_die_Aufgaben_das_Tagesbudget_des_Agenten_uebersteigen()
|
||||||
|
{
|
||||||
|
var knapp = new AgentConfig { AgentId = "nachrichten", Model = Nachrichten.Model, SystemPrompt = "x" };
|
||||||
|
knapp.Budget.DailyCostUsd = 0.40m;
|
||||||
|
|
||||||
|
var p = KostenPrognose.Berechne(
|
||||||
|
[Stuendlich("t-1")], Belege(("t-1", [Beleg("t-1", 0.02m)])), [], [knapp], null, Jetzt);
|
||||||
|
|
||||||
|
var warnung = p.Budgetwarnungen.ShouldHaveSingleItem();
|
||||||
|
warnung.AgentId.ShouldBe("nachrichten");
|
||||||
|
warnung.KostenProTag.ShouldBe(0.48m);
|
||||||
|
warnung.BudgetProTag.ShouldBe(0.40m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)] // keine Grenze gesetzt
|
||||||
|
[InlineData(1.0)] // Budget reicht
|
||||||
|
public void Ohne_Grenze_oder_mit_Luft_keine_Budgetwarnung(double budget)
|
||||||
|
{
|
||||||
|
var agent = new AgentConfig { AgentId = "nachrichten", Model = Nachrichten.Model, SystemPrompt = "x" };
|
||||||
|
agent.Budget.DailyCostUsd = (decimal)budget;
|
||||||
|
|
||||||
|
KostenPrognose.Berechne([Stuendlich("t-1")], Belege(("t-1", [Beleg("t-1", 0.02m)])), [], [agent], null, Jetzt)
|
||||||
|
.Budgetwarnungen.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Werkzeugjob_zaehlt_nur_tatsaechliche_Weckungen()
|
public void Werkzeugjob_zaehlt_nur_tatsaechliche_Weckungen()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using ClawdDotNet.Core.Accounting;
|
||||||
|
using ClawdDotNet.Core.Config;
|
||||||
|
using ClawdDotNet.Core.Engine;
|
||||||
|
using ClawdDotNet.Core.Tests.Infrastructure;
|
||||||
|
using Shouldly;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Core.Tests.Budget;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #608: Ein wegen erschoepften Budgets abgelehnter Lauf hinterliess nur eine Log-Warnung -
|
||||||
|
/// kein Beleg, keine Anzeige an der Aufgabe. Im Testbetrieb fehlten deshalb ab 18:20 alle
|
||||||
|
/// Wetterdaten, ohne dass es ausserhalb des Logs zu sehen war.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BudgetsperreSichtbarTests
|
||||||
|
{
|
||||||
|
/// <summary>Meldet fuer jeden Agenten einen Tagesverbrauch ueber jeder ueblichen Grenze.</summary>
|
||||||
|
private sealed class ErschoepftesBudget : IUsageRepository
|
||||||
|
{
|
||||||
|
public Task RecordAsync(RunUsage usage, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task<DailyUsage> GetDailyAsync(DateOnly date, string agentId, CancellationToken ct)
|
||||||
|
=> Task.FromResult(new DailyUsage(date, 400_000, 20_000, 0.408m, true, 28));
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<RunUsage>> GetRecentAsync(int limit, CancellationToken ct)
|
||||||
|
=> Task.FromResult<IReadOnlyList<RunUsage>>([]);
|
||||||
|
|
||||||
|
public Task<int> PurgeBeforeAsync(DateOnly date, CancellationToken ct) => Task.FromResult(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (EngineFixture Fixture, InMemoryAuditRepository Audit, AgentConfig Agent) Aufbau()
|
||||||
|
{
|
||||||
|
var audit = new InMemoryAuditRepository();
|
||||||
|
var fixture = new EngineFixture(audit, usage: new ErschoepftesBudget());
|
||||||
|
var agent = fixture.AddAgent("wetterdienst");
|
||||||
|
agent.Budget.DailyCostUsd = 0.40m;
|
||||||
|
return (fixture, audit, agent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Abgelehnter_frischer_Aufgabenlauf_hinterlaesst_einen_Beleg_an_der_Aufgabe()
|
||||||
|
{
|
||||||
|
var (fixture, audit, agent) = Aufbau();
|
||||||
|
|
||||||
|
var result = await fixture.Engine.RunAsync(
|
||||||
|
agent, "Hole die Warnungen", "inst", CancellationToken.None, source: "task", taskId: "t-unwetter");
|
||||||
|
|
||||||
|
result.Status.ShouldBe(AgentRunStatus.BudgetExceeded);
|
||||||
|
var beleg = audit.Receipts.ShouldHaveSingleItem();
|
||||||
|
beleg.TaskId.ShouldBe("t-unwetter");
|
||||||
|
beleg.AgentId.ShouldBe("wetterdienst");
|
||||||
|
beleg.Status.ShouldBe(nameof(AgentRunStatus.BudgetExceeded));
|
||||||
|
beleg.ResultRef.ShouldContain("Budget erschöpft");
|
||||||
|
beleg.ResultRef.ShouldContain("0,40", Case.Insensitive);
|
||||||
|
beleg.PromptTokens.ShouldBe(0);
|
||||||
|
beleg.CostUsd.ShouldBe(0m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Abgelehnter_Lauf_im_Chatverlauf_hinterlaesst_ebenfalls_einen_Beleg()
|
||||||
|
{
|
||||||
|
var (fixture, audit, agent) = Aufbau();
|
||||||
|
|
||||||
|
var result = await fixture.Engine.ChatAsync(
|
||||||
|
agent, "Digest", "inst", CancellationToken.None, source: "task", taskId: "t-digest");
|
||||||
|
|
||||||
|
result.Status.ShouldBe(AgentRunStatus.BudgetExceeded);
|
||||||
|
audit.Receipts.ShouldHaveSingleItem().TaskId.ShouldBe("t-digest");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Jede_weitere_Ablehnung_des_Tages_steht_als_eigener_Beleg_da()
|
||||||
|
{
|
||||||
|
var (fixture, audit, agent) = Aufbau();
|
||||||
|
|
||||||
|
for (var i = 0; i < 3; i++)
|
||||||
|
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None, source: "task", taskId: "t-" + i);
|
||||||
|
|
||||||
|
audit.Receipts.Select(r => r.TaskId).ShouldBe(["t-0", "t-1", "t-2"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ClawdDotNet.Core.Accounting;
|
||||||
using ClawdDotNet.Core.Audit;
|
using ClawdDotNet.Core.Audit;
|
||||||
using ClawdDotNet.Core.Config;
|
using ClawdDotNet.Core.Config;
|
||||||
using ClawdDotNet.Core.Engine;
|
using ClawdDotNet.Core.Engine;
|
||||||
@@ -23,7 +24,9 @@ internal sealed class EngineFixture
|
|||||||
/// <summary>Die registrierten Agenten — für Tests, die einen Dispatcher selbst bauen.</summary>
|
/// <summary>Die registrierten Agenten — für Tests, die einen Dispatcher selbst bauen.</summary>
|
||||||
public IReadOnlyList<AgentConfig> Agents => _agents;
|
public IReadOnlyList<AgentConfig> Agents => _agents;
|
||||||
|
|
||||||
public EngineFixture(IAuditRepository? audit = null, StagingGate? staging = null, TimeProvider? clock = null)
|
public EngineFixture(
|
||||||
|
IAuditRepository? audit = null, StagingGate? staging = null, TimeProvider? clock = null,
|
||||||
|
IUsageRepository? usage = null)
|
||||||
{
|
{
|
||||||
Engine = new AgentEngine(
|
Engine = new AgentEngine(
|
||||||
Client,
|
Client,
|
||||||
@@ -31,6 +34,7 @@ internal sealed class EngineFixture
|
|||||||
new PermissionGate(),
|
new PermissionGate(),
|
||||||
StateStore,
|
StateStore,
|
||||||
TestLogging.Factory,
|
TestLogging.Factory,
|
||||||
|
usageRepository: usage,
|
||||||
auditRepository: audit,
|
auditRepository: audit,
|
||||||
stagingGate: staging,
|
stagingGate: staging,
|
||||||
clock: clock);
|
clock: clock);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.Database\ClawdDotNet.Tools.Database.csproj" />
|
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.Database\ClawdDotNet.Tools.Database.csproj" />
|
||||||
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.DirectAPI\ClawdDotNet.Tools.DirectAPI.csproj" />
|
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.DirectAPI\ClawdDotNet.Tools.DirectAPI.csproj" />
|
||||||
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.FileRW\ClawdDotNet.Tools.FileRW.csproj" />
|
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.FileRW\ClawdDotNet.Tools.FileRW.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.Memory\ClawdDotNet.Tools.Memory.csproj" />
|
||||||
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.SocialMediaManager\ClawdDotNet.Tools.SocialMediaManager.csproj" />
|
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.SocialMediaManager\ClawdDotNet.Tools.SocialMediaManager.csproj" />
|
||||||
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.WebFetch\ClawdDotNet.Tools.WebFetch.csproj" />
|
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.WebFetch\ClawdDotNet.Tools.WebFetch.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ClawdDotNet.Core.Memory;
|
||||||
|
using ClawdDotNet.Core.Storage;
|
||||||
|
using ClawdDotNet.Core.Tools;
|
||||||
|
using ClawdDotNet.Tools.Memory;
|
||||||
|
using ClawdDotNet.Tools.Tests.FileRW;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Shouldly;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Tools.Tests.Memory;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #607: Der Nachrichten-Agent merkte sich bei jedem Lauf die ganze, wachsende Linkliste ohne
|
||||||
|
/// Schluessel. Sobald der neueste Eintrag allein ueber 8.000 Zeichen lag, lieferte recall keinen
|
||||||
|
/// einzigen Inhalt mehr - der Agent haette jede Meldung erneut fuer neu gehalten.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemoryToolTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _dir = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
|
||||||
|
private readonly SqliteMemoryRepository _repository;
|
||||||
|
private readonly MemoryTool _tool = new();
|
||||||
|
|
||||||
|
public MemoryToolTests()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_dir);
|
||||||
|
_repository = new SqliteMemoryRepository(new SqliteStorage(Path.Combine(_dir, "state.db")));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||||
|
try { Directory.Delete(_dir, recursive: true); }
|
||||||
|
catch { /* Aufraeumen ist Nebensache */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<ToolResult> Aufruf(object eingabe) => _tool.ExecuteAsync(
|
||||||
|
JsonSerializer.SerializeToElement(eingabe),
|
||||||
|
new AgentToolContext("nachrichten", "inst", new Dictionary<string, object?>(), new NullStateStore(),
|
||||||
|
NullLogger.Instance, CancellationToken.None, Memory: _repository),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
private static string Linkliste(int anzahl)
|
||||||
|
=> string.Join("\n", Enumerable.Range(1, anzahl).Select(i => $"https://www.tagesschau.de/inland/meldung-{i:D4}.html"));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Ein_einzelner_uebergrosser_Eintrag_wird_gekuerzt_statt_weggelassen()
|
||||||
|
{
|
||||||
|
var liste = Linkliste(250); // ~11.500 Zeichen
|
||||||
|
(await Aufruf(new { action = "remember", subject = "gemeldet", content = liste })).Success.ShouldBeTrue();
|
||||||
|
|
||||||
|
var abruf = await Aufruf(new { action = "recall", query = "gemeldet" });
|
||||||
|
|
||||||
|
abruf.Success.ShouldBeTrue();
|
||||||
|
abruf.Content.ShouldContain("meldung-0001.html", customMessage: "der Anfang bleibt sichtbar");
|
||||||
|
abruf.Content.ShouldContain("meldung-0250.html", customMessage: "das Ende - die neuesten Links - bleibt sichtbar");
|
||||||
|
abruf.Content.ShouldContain("gekürzt");
|
||||||
|
abruf.Content.ShouldNotContain("weitere Treffer ausgelassen");
|
||||||
|
abruf.Content.Length.ShouldBeLessThanOrEqualTo(8_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Ist_der_neueste_von_mehreren_Eintraegen_zu_gross_bleibt_er_teilweise_sichtbar()
|
||||||
|
{
|
||||||
|
await Aufruf(new { action = "remember", subject = "gemeldet", content = Linkliste(100) });
|
||||||
|
await Task.Delay(20);
|
||||||
|
await Aufruf(new { action = "remember", subject = "gemeldet", content = Linkliste(220) });
|
||||||
|
|
||||||
|
var abruf = await Aufruf(new { action = "recall", query = "gemeldet" });
|
||||||
|
|
||||||
|
abruf.Content.ShouldContain("meldung-0220.html");
|
||||||
|
abruf.Content.ShouldContain("1 weitere Treffer ausgelassen");
|
||||||
|
abruf.Content.Length.ShouldBeLessThanOrEqualTo(8_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Was_passt_bleibt_ungekuerzt()
|
||||||
|
{
|
||||||
|
await Aufruf(new { action = "remember", subject = "gemeldet", content = Linkliste(20) });
|
||||||
|
|
||||||
|
var abruf = await Aufruf(new { action = "recall", query = "gemeldet" });
|
||||||
|
|
||||||
|
abruf.Content.ShouldNotContain("gekürzt");
|
||||||
|
abruf.Content.ShouldContain("meldung-0010.html");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Erneutes_Merken_ohne_Schluessel_unter_demselben_Betreff_weist_auf_key_hin()
|
||||||
|
{
|
||||||
|
var erster = await Aufruf(new { action = "remember", subject = "gemeldet", content = Linkliste(3) });
|
||||||
|
var zweiter = await Aufruf(new { action = "remember", subject = "gemeldet", content = Linkliste(5) });
|
||||||
|
|
||||||
|
erster.Content.ShouldNotContain("Hinweis");
|
||||||
|
zweiter.Content.ShouldContain("2 Einträge ohne Schlüssel");
|
||||||
|
zweiter.Content.ShouldContain("key");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Mit_Schluessel_wird_fortgeschrieben_und_nicht_gewarnt()
|
||||||
|
{
|
||||||
|
await Aufruf(new { action = "remember", subject = "gemeldet", key = "gemeldete-links", content = Linkliste(3) });
|
||||||
|
var zweiter = await Aufruf(new { action = "remember", subject = "gemeldet", key = "gemeldete-links", content = Linkliste(5) });
|
||||||
|
|
||||||
|
zweiter.Content.ShouldNotContain("Hinweis");
|
||||||
|
(await _repository.CountAsync(MemoryScope.Agent, "nachrichten", CancellationToken.None)).ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Ein_Eintrag_nahe_der_Abrufgrenze_wird_beim_Merken_angemahnt()
|
||||||
|
{
|
||||||
|
var ergebnis = await Aufruf(new { action = "remember", subject = "gemeldet", key = "links", content = Linkliste(120) });
|
||||||
|
|
||||||
|
ergebnis.Content.ShouldContain("Zeichen lang");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user