Budgetsperre sichtbar machen (#608), Gedaechtnisabruf kuerzt statt wegzulassen (#607)

#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:
Richard
2026-09-15 18:59:14 +02:00
co-authored by Claude Opus 5
parent b0997b5334
commit f5731c50f8
12 changed files with 408 additions and 22 deletions
@@ -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"]);
}
}