#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:
@@ -113,6 +113,46 @@ public sealed class KostenPrognoseTests
|
||||
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]
|
||||
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.Config;
|
||||
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>
|
||||
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(
|
||||
Client,
|
||||
@@ -31,6 +34,7 @@ internal sealed class EngineFixture
|
||||
new PermissionGate(),
|
||||
StateStore,
|
||||
TestLogging.Factory,
|
||||
usageRepository: usage,
|
||||
auditRepository: audit,
|
||||
stagingGate: staging,
|
||||
clock: clock);
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<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.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.WebFetch\ClawdDotNet.Tools.WebFetch.csproj" />
|
||||
</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