Bugtracker-Durchgang: 13 Bugs behoben, Build ohne Warnungen
Alle Behebungen auf der Linux-Test-VM gegengeprueft (Ubuntu, .NET 10.0.111): Build 0 Warnungen / 0 Fehler, 637 Tests gruen, 1 uebersprungen (WindowsFact). Sicherheit und Zielplattform #373 PathBoundary.Canonicalize fiel offen aus. File.ResolveLinkTarget wirft fuer einen fehlenden Abschnitt FileNotFoundException; der catch(IOException) verliess daraufhin die GANZE Aufloesung mit dem unaufgeloesten Pfad - und der besteht jede Praefixpruefung. Der Symlink-Schutz war damit bei jedem Schreibvorgang wirkungslos. Jetzt ueber FileSystemInfo.LinkTarget, und TryCanonicalize meldet "nicht bestimmbar", statt fail-open durchzureichen. #374 WorkspacePath weist den Backslash rundheraus ab, wie den Doppelpunkt. Eine Schreibweise statt einer Sonderregel je Windows-Form. #375 Log-Verzeichnis: leer heisst "Anwendungseinstellung"; ein relativer Wert zaehlt gegen das Instanzverzeichnis, nicht gegen das Arbeitsverzeichnis des Prozesses. Ein systemd-Dienst landet nicht mehr auf /Logs. Agentenkommunikation #206 Delegationskette gegen A->B->A (Test A13). Zyklusfrei per Bauart, dazu MaxDelegationDepth gegen die Staffel, die niemand beauftragt hat. #461 Zwei unabhaengige Ketten ueber Kreuz - beim Beheben von #206 gefunden und reproduziert. Ein delegierter Lauf wartet nur noch begrenzt auf ein fremdes Gate; ein Chat vom Menschen reiht sich weiter unbegrenzt ein. #234 Instanz-Id wird durchgereicht statt aus einem nur beilaeufig gesetzten Feld gelesen. Betrieb und Oberflaeche #376 Log-Ansicht chronologisch statt gruppiert je Moduldatei, mit Stacktraces an ihrer Meldung - auch ueber Taktgrenzen hinweg. #377 Info-Tabelle: feste Spaltenbreiten, damit ein langer Aenderungstext den anderen Spalten nicht den Platz nimmt. #378 Sicherung: Mindestabstand zwischen Ueberschrift und Schaltflaechenleiste. #379 Hellmodus: drei von vier gedaempften Textpinseln lagen unter 4,5:1. Die helle Palette ist als Ganzes verschoben, damit die Staffelung sichtbar bleibt statt auf einen Wert zusammenzufallen. Hygiene #233 stock_add: der Indexstand wird innerhalb der Sperre festgehalten. #235 Zeitumstellung: eine nicht existierende Ortszeit wird auf den ersten Zeitpunkt nach der Luecke verschoben statt uebersprungen (R4, R5). #236 45 Nullable-Warnungen und eine CA1416 - nicht unterdrueckt, sondern die Stellen so umgebaut, dass die Zusage nachvollziehbar wird. Mail meldet jetzt, WELCHER Zugangsschluessel fehlt, statt an einer KeyNotFoundException zu scheitern. #203 Kompaktiert wird vor dem API-Aufruf statt danach; die gemessenen Prompt-Groessen kalibrieren die Schaetzung je Modell. Neu: tests/ClawdDotNet.App.Tests - fuer #375 und #376 gab es keinen Ort fuer Tests. Dazu Regressionstests fuer jede Behebung; jeder wurde gegen den alten Stand geprueft, damit belegt ist, dass er den Fehler faengt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -37,7 +37,7 @@ public sealed class ContextCompactorPropertyTests
|
||||
var client = new FakeChatClient().AlwaysRespondsWithText("- Zusammenfassung des Verlaufs.");
|
||||
var compactor = new ContextCompactor(client, TestLogging.Factory);
|
||||
|
||||
compactor.CompactIfNeededAsync(messages, 50_000, AlwaysCompact, Model, default)
|
||||
compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
var valid = ContextInvariants.IsValid(messages);
|
||||
@@ -55,7 +55,7 @@ public sealed class ContextCompactorPropertyTests
|
||||
var client = new FakeChatClient().AlwaysRespondsWithText("- Zusammenfassung.");
|
||||
var compactor = new ContextCompactor(client, TestLogging.Factory);
|
||||
|
||||
compactor.CompactIfNeededAsync(messages, 50_000, AlwaysCompact, Model, default)
|
||||
compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
var systemCount = messages.Count(m => m.Role == "system");
|
||||
|
||||
@@ -59,7 +59,7 @@ public sealed class ContextCompactorTests
|
||||
|
||||
ContextInvariants.AssertValid(messages); // Vorbedingung: Ausgangslage ist gültig
|
||||
|
||||
await compactor.CompactIfNeededAsync(messages, 5_000, AlwaysCompact, Model, default);
|
||||
await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
|
||||
|
||||
ContextInvariants.AssertValid(messages);
|
||||
}
|
||||
@@ -77,7 +77,7 @@ public sealed class ContextCompactorTests
|
||||
.ToolCycle(count: 3, resultLength: 300)
|
||||
.Build();
|
||||
|
||||
await compactor.CompactIfNeededAsync(messages, 5_000, AlwaysCompact, Model, default);
|
||||
await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
|
||||
|
||||
ContextInvariants.AssertValid(messages);
|
||||
|
||||
@@ -107,7 +107,7 @@ public sealed class ContextCompactorTests
|
||||
var (compactor, _) = CreateCompactor();
|
||||
var messages = Conversation.Start(systemPrompt).Repeat(6).Build();
|
||||
|
||||
await compactor.CompactIfNeededAsync(messages, 5_000, AlwaysCompact, Model, default);
|
||||
await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
|
||||
|
||||
messages[0].Role.ShouldBe("system");
|
||||
messages[0].Content.ShouldBe(systemPrompt);
|
||||
@@ -120,7 +120,7 @@ public sealed class ContextCompactorTests
|
||||
var (compactor, _) = CreateCompactor();
|
||||
var messages = Conversation.Start(systemPrompt: null).Repeat(6).Build();
|
||||
|
||||
await compactor.CompactIfNeededAsync(messages, 5_000, AlwaysCompact, Model, default);
|
||||
await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
|
||||
|
||||
messages.ShouldNotContain(m => m.Role == "system");
|
||||
}
|
||||
@@ -137,7 +137,7 @@ public sealed class ContextCompactorTests
|
||||
var messages = Conversation.Start().Repeat(6).Build();
|
||||
var before = messages.Count;
|
||||
|
||||
await compactor.CompactIfNeededAsync(messages, 5_000, AlwaysCompact, Model, default);
|
||||
await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
|
||||
|
||||
ContextInvariants.AssertValid(messages);
|
||||
messages.Count.ShouldBe(before, "bei fehlgeschlagener Zusammenfassung darf nichts verloren gehen");
|
||||
@@ -151,7 +151,7 @@ public sealed class ContextCompactorTests
|
||||
var messages = Conversation.Start().Repeat(6).Build();
|
||||
var before = messages.Count;
|
||||
|
||||
await compactor.CompactIfNeededAsync(messages, 5_000, AlwaysCompact, Model, default);
|
||||
await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
|
||||
|
||||
ContextInvariants.AssertValid(messages);
|
||||
messages.Count.ShouldBe(before);
|
||||
@@ -169,7 +169,7 @@ public sealed class ContextCompactorTests
|
||||
.Repeat(times: 5, toolCallsPerCycle: 1, resultLength: 8_000)
|
||||
.Build();
|
||||
|
||||
await compactor.CompactIfNeededAsync(messages, 5_000, AlwaysCompact, Model, default);
|
||||
await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
|
||||
|
||||
ContextInvariants.AssertValid(messages);
|
||||
messages.Where(m => m.Role == "tool")
|
||||
@@ -185,7 +185,7 @@ public sealed class ContextCompactorTests
|
||||
.Repeat(times: 5, toolCallsPerCycle: 1, resultLength: 100)
|
||||
.Build();
|
||||
|
||||
await compactor.CompactIfNeededAsync(messages, 5_000, AlwaysCompact, Model, default);
|
||||
await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
|
||||
|
||||
foreach (var content in messages.Where(m => m.Role == "tool").Select(m => m.Content))
|
||||
(content ?? "").ShouldNotContain("gekürzt", Case.Insensitive);
|
||||
@@ -217,7 +217,7 @@ public sealed class ContextCompactorTests
|
||||
var (compactor, client) = CreateCompactor();
|
||||
var messages = Conversation.Start().Repeat(2).Build();
|
||||
|
||||
var compacted = await compactor.CompactIfNeededAsync(messages, 10, NeverCompact, Model, default);
|
||||
var compacted = await compactor.CompactIfNeededAsync(messages, NeverCompact, Model, default);
|
||||
|
||||
compacted.ShouldBeFalse();
|
||||
client.CallCount.ShouldBe(0);
|
||||
@@ -230,7 +230,7 @@ public sealed class ContextCompactorTests
|
||||
var messages = Conversation.Start().Repeat(15).Build();
|
||||
var before = messages.Count;
|
||||
|
||||
var compacted = await compactor.CompactIfNeededAsync(messages, 50_000, AlwaysCompact, Model, default);
|
||||
var compacted = await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
|
||||
|
||||
compacted.ShouldBeTrue();
|
||||
messages.Count.ShouldBeLessThan(before);
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
using ClawdDotNet.Core.Engine;
|
||||
using ClawdDotNet.Core.Tests.Infrastructure;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Test A13 der Teststrategie — Regressionstests für B8 (#206).
|
||||
///
|
||||
/// <para><c>send_message</c> ist ein synchroner Aufruf: A hält sein Gate, während es
|
||||
/// auf B wartet. Rief B nun A, wartete B auf ein Gate, das A erst freigibt, wenn B
|
||||
/// geantwortet hat. Beide standen, bis der Timeout des äußeren Laufs sie auflöste.</para>
|
||||
///
|
||||
/// <para>Die Tests prüfen die drei Zusagen aus <see cref="DelegationChain"/>: kein
|
||||
/// Agent zweimal in einer Kette, höchstens <c>MaxDelegationDepth</c> Weitergaben, und
|
||||
/// die Kette gehört zum Aufruf statt zum Agenten.</para>
|
||||
///
|
||||
/// <para>Ein Zeitlimit steht bewusst an jedem Test, der verklemmen könnte: Ohne die
|
||||
/// Prüfung liefe er nicht rot, sondern gar nicht mehr weiter.</para>
|
||||
/// </summary>
|
||||
public sealed class DelegationDepthTests
|
||||
{
|
||||
private static readonly TimeSpan Frist = TimeSpan.FromSeconds(20);
|
||||
|
||||
/// <summary>Wartet auf den Lauf, scheitert aber statt zu hängen.</summary>
|
||||
private static async Task<AgentRunResult> MitFrist(Task<AgentRunResult> lauf)
|
||||
{
|
||||
var fertig = await Task.WhenAny(lauf, Task.Delay(Frist));
|
||||
fertig.ShouldBe(lauf, "Der Lauf ist nicht zurückgekommen — das ist die Verklemmung aus B8.");
|
||||
return await lauf;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// A13 — der gemeldete Fall
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task A_nach_B_nach_A_wird_abgebrochen_statt_zu_verklemmen()
|
||||
{
|
||||
var tool = new DelegatingTool(new Dictionary<string, string>
|
||||
{
|
||||
["agent-a"] = "agent-b",
|
||||
["agent-b"] = "agent-a" // zurück zu A — der Zyklus
|
||||
});
|
||||
|
||||
var fixture = new EngineFixture().WithTool(tool);
|
||||
var a = fixture.AddAgent("agent-a", "TestTool");
|
||||
fixture.AddAgent("agent-b", "TestTool");
|
||||
|
||||
fixture.Client.RespondsContextually();
|
||||
|
||||
var result = await MitFrist(
|
||||
fixture.Engine.ChatAsync(a, "Bitte klären", "test-instance", default));
|
||||
|
||||
result.Status.ShouldBe(AgentRunStatus.Completed);
|
||||
|
||||
// Der Rückweg B → A ist abgelehnt worden, der Hinweg A → B nicht.
|
||||
var rueckweg = tool.Handoffs.Single(h => h is { From: "agent-b", To: "agent-a" });
|
||||
rueckweg.Delivered.ShouldBeFalse();
|
||||
rueckweg.Error.ShouldNotBeNull().ShouldContain("Zyklus");
|
||||
rueckweg.Error.ShouldNotBeNull().ShouldContain("agent-a -> agent-b -> agent-a");
|
||||
|
||||
tool.Handoffs.Single(h => h is { From: "agent-a", To: "agent-b" }).Delivered.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Die_Ablehnung_kommt_als_Ergebnis_zurueck_nicht_als_Wurf()
|
||||
{
|
||||
// Der abgewiesene Agent soll weiterarbeiten können — er bekommt einen Grund,
|
||||
// keinen Abbruch. Sonst führte die Zyklusprüfung nur zu einem anderen Ausfall.
|
||||
var tool = new DelegatingTool(new Dictionary<string, string>
|
||||
{
|
||||
["agent-a"] = "agent-b",
|
||||
["agent-b"] = "agent-a"
|
||||
});
|
||||
|
||||
var fixture = new EngineFixture().WithTool(tool);
|
||||
var a = fixture.AddAgent("agent-a", "TestTool");
|
||||
fixture.AddAgent("agent-b", "TestTool");
|
||||
|
||||
fixture.Client.RespondsContextually();
|
||||
|
||||
var result = await MitFrist(
|
||||
fixture.Engine.ChatAsync(a, "Bitte klären", "test-instance", default));
|
||||
|
||||
result.Status.ShouldBe(AgentRunStatus.Completed);
|
||||
result.FinalMessage.ShouldBe("Fertig");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Die Staffel, die keinen Zyklus bildet
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Eine_Staffel_endet_an_der_Tiefengrenze()
|
||||
{
|
||||
// A → B → C → D wäre die Tiefe 3. Mit MaxDelegationDepth = 2 ist bei C Schluss.
|
||||
var tool = new DelegatingTool(new Dictionary<string, string>
|
||||
{
|
||||
["agent-a"] = "agent-b",
|
||||
["agent-b"] = "agent-c",
|
||||
["agent-c"] = "agent-d"
|
||||
});
|
||||
|
||||
var fixture = new EngineFixture().WithTool(tool);
|
||||
fixture.Engine.MaxDelegationDepth = 2;
|
||||
|
||||
var a = fixture.AddAgent("agent-a", "TestTool");
|
||||
fixture.AddAgent("agent-b", "TestTool");
|
||||
fixture.AddAgent("agent-c", "TestTool");
|
||||
fixture.AddAgent("agent-d", "TestTool");
|
||||
|
||||
fixture.Client.RespondsContextually();
|
||||
|
||||
await MitFrist(fixture.Engine.ChatAsync(a, "Bitte klären", "test-instance", default));
|
||||
|
||||
tool.Handoffs.Single(h => h.To == "agent-b").Delivered.ShouldBeTrue();
|
||||
tool.Handoffs.Single(h => h.To == "agent-c").Delivered.ShouldBeTrue();
|
||||
|
||||
var zuTief = tool.Handoffs.Single(h => h.To == "agent-d");
|
||||
zuTief.Delivered.ShouldBeFalse();
|
||||
zuTief.Error.ShouldNotBeNull().ShouldContain("Delegationstiefe");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Innerhalb_der_Grenze_geht_die_Staffel_durch()
|
||||
{
|
||||
var tool = new DelegatingTool(new Dictionary<string, string>
|
||||
{
|
||||
["agent-a"] = "agent-b",
|
||||
["agent-b"] = "agent-c"
|
||||
});
|
||||
|
||||
var fixture = new EngineFixture().WithTool(tool);
|
||||
fixture.Engine.MaxDelegationDepth = 2;
|
||||
|
||||
var a = fixture.AddAgent("agent-a", "TestTool");
|
||||
fixture.AddAgent("agent-b", "TestTool");
|
||||
fixture.AddAgent("agent-c", "TestTool");
|
||||
|
||||
fixture.Client.RespondsContextually();
|
||||
|
||||
var result = await MitFrist(
|
||||
fixture.Engine.ChatAsync(a, "Bitte klären", "test-instance", default));
|
||||
|
||||
result.Status.ShouldBe(AgentRunStatus.Completed);
|
||||
tool.Handoffs.ShouldAllBe(h => h.Delivered);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Die Kette gehört zum Aufruf, nicht zum Agenten
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Zwei_Laeufe_nacheinander_erben_die_Kette_des_anderen_nicht()
|
||||
{
|
||||
// Sonst könnte ein Agent nach einer tiefen Kette dauerhaft „verbraucht" sein.
|
||||
var tool = new DelegatingTool(new Dictionary<string, string> { ["agent-a"] = "agent-b" });
|
||||
|
||||
var fixture = new EngineFixture().WithTool(tool);
|
||||
var a = fixture.AddAgent("agent-a", "TestTool");
|
||||
fixture.AddAgent("agent-b", "TestTool");
|
||||
|
||||
fixture.Client.RespondsContextually();
|
||||
|
||||
await MitFrist(fixture.Engine.ChatAsync(a, "Erste Frage", "test-instance", default));
|
||||
await MitFrist(fixture.Engine.ChatAsync(a, "Zweite Frage", "test-instance", default));
|
||||
|
||||
tool.Handoffs.Count(h => h.To == "agent-b").ShouldBe(2);
|
||||
tool.Handoffs.ShouldAllBe(h => h.Delivered);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// spawn zählt mit
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Auch_ueber_spawn_laesst_sich_die_Kette_nicht_umgehen()
|
||||
{
|
||||
var tool = new DelegatingTool(new Dictionary<string, string>
|
||||
{
|
||||
["agent-a"] = "agent-b",
|
||||
["agent-b"] = "agent-a"
|
||||
}, spawn: true);
|
||||
|
||||
var fixture = new EngineFixture().WithTool(tool);
|
||||
var a = fixture.AddAgent("agent-a", "TestTool");
|
||||
fixture.AddAgent("agent-b", "TestTool");
|
||||
|
||||
fixture.Client.RespondsContextually();
|
||||
|
||||
await MitFrist(fixture.Engine.ChatAsync(a, "Bitte klären", "test-instance", default));
|
||||
|
||||
var rueckweg = tool.Handoffs.Single(h => h is { From: "agent-b", To: "agent-a" });
|
||||
rueckweg.Delivered.ShouldBeFalse();
|
||||
rueckweg.Error.ShouldNotBeNull().ShouldContain("Zyklus");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// #461 — was die Kette grundsätzlich nicht sehen kann
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Zwei unabhängige Ketten über Kreuz: Lauf 1 startet bei A und beauftragt B, Lauf 2
|
||||
/// startet zeitgleich bei B und beauftragt A. Keine der beiden Ketten enthält den
|
||||
/// Agenten der anderen — beide sind für sich zyklusfrei, und trotzdem hält Lauf 1
|
||||
/// gate(A) und wartet auf gate(B), während Lauf 2 gate(B) hält und auf gate(A) wartet.
|
||||
///
|
||||
/// <para>Das kann die Delegationskette prinzipiell nicht erkennen; nur eine Frist
|
||||
/// löst es auf. Ein delegierter Lauf wartet deshalb höchstens
|
||||
/// <c>DelegationGateTimeout</c> auf ein fremdes Gate und sagt danach ab.</para>
|
||||
///
|
||||
/// <para>Die Pause vor der Weitergabe ist der Kern des Aufbaus: Der Fake-Client
|
||||
/// antwortet synchron, ohne sie liefe der erste Chat vollständig durch, bevor der
|
||||
/// zweite beginnt — die Ketten könnten sich gar nicht überschneiden, und der Test
|
||||
/// wäre grün, ohne etwas zu zeigen.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Zwei_unabhaengige_Ketten_ueber_Kreuz_verklemmen_sich_nicht()
|
||||
{
|
||||
var tool = new DelegatingTool(new Dictionary<string, string>
|
||||
{
|
||||
["agent-a"] = "agent-b",
|
||||
["agent-b"] = "agent-a"
|
||||
}, delayBeforeHandoff: TimeSpan.FromMilliseconds(300));
|
||||
|
||||
var fixture = new EngineFixture().WithTool(tool);
|
||||
fixture.Engine.DelegationGateTimeout = TimeSpan.FromSeconds(1);
|
||||
|
||||
var a = fixture.AddAgent("agent-a", "TestTool");
|
||||
var b = fixture.AddAgent("agent-b", "TestTool");
|
||||
|
||||
fixture.Client.RespondsContextually();
|
||||
|
||||
var laufA = fixture.Engine.ChatAsync(a, "A fragt B", "test-instance", default);
|
||||
var laufB = fixture.Engine.ChatAsync(b, "B fragt A", "test-instance", default);
|
||||
|
||||
var beide = Task.WhenAll(laufA, laufB);
|
||||
var fertig = await Task.WhenAny(beide, Task.Delay(Frist));
|
||||
|
||||
fertig.ShouldBe(beide, "Beide Laeufe warten auf das Gate des jeweils anderen (#461).");
|
||||
|
||||
// Beide äußeren Läufe kommen zu Ende. Mindestens eine Weitergabe ist an der
|
||||
// Frist gescheitert — welche, entscheidet die Reihenfolge und ist nicht
|
||||
// festgelegt; dass beide Läufe zurückkommen, schon.
|
||||
(await laufA).Status.ShouldBe(AgentRunStatus.Completed);
|
||||
(await laufB).Status.ShouldBe(AgentRunStatus.Completed);
|
||||
tool.Handoffs.ShouldContain(h => !h.Delivered && h.Error!.Contains("belegt"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_Chat_vom_Menschen_wartet_weiter_so_lange_es_dauert()
|
||||
{
|
||||
// Die Gegenprobe zur Frist: Sie gilt nur für delegierte Läufe. Ein zweiter
|
||||
// Chat desselben Agenten reiht sich ein und wird bedient, auch wenn der erste
|
||||
// länger braucht als die Delegationsfrist.
|
||||
var fixture = new EngineFixture().WithTool(FakeTool.Slow(TimeSpan.FromMilliseconds(600)));
|
||||
fixture.Engine.DelegationGateTimeout = TimeSpan.FromMilliseconds(50);
|
||||
|
||||
var a = fixture.AddAgent("agent-a", "TestTool");
|
||||
|
||||
fixture.Client.RespondsContextually();
|
||||
|
||||
var erster = fixture.Engine.ChatAsync(a, "Erste Frage", "test-instance", default);
|
||||
var zweiter = fixture.Engine.ChatAsync(a, "Zweite Frage", "test-instance", default);
|
||||
|
||||
(await MitFrist(erster)).Status.ShouldBe(AgentRunStatus.Completed);
|
||||
(await MitFrist(zweiter)).Status.ShouldBe(AgentRunStatus.Completed);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Die Kette selbst
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public void Die_Kette_zaehlt_Weitergaben_nicht_Agenten()
|
||||
{
|
||||
var kette = DelegationChain.Root("a");
|
||||
kette.Depth.ShouldBe(0);
|
||||
kette.Append("b").Depth.ShouldBe(1);
|
||||
kette.Append("b").Append("c").Depth.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Append_veraendert_die_Kette_des_Aufrufers_nicht()
|
||||
{
|
||||
// Unveränderlich: Zwei Weitergaben aus demselben Lauf dürfen sich nicht
|
||||
// gegenseitig sehen — sonst zählte die zweite die erste als Vorgänger.
|
||||
var kette = DelegationChain.Root("a");
|
||||
|
||||
var nachB = kette.Append("b");
|
||||
var nachC = kette.Append("c");
|
||||
|
||||
kette.Agents.ShouldBe(["a"]);
|
||||
nachB.Agents.ShouldBe(["a", "b"]);
|
||||
nachC.Agents.ShouldBe(["a", "c"]);
|
||||
nachB.Contains("c").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Die_Kette_liest_sich_als_Weg()
|
||||
{
|
||||
DelegationChain.Root("a").Append("b").Append("c").ToString()
|
||||
.ShouldBe("a -> b -> c");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using ClawdDotNet.Core.Engine;
|
||||
using ClawdDotNet.Core.Tests.Infrastructure;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// #203 — die Compaction lief nach dem API-Aufruf.
|
||||
///
|
||||
/// <para>Sie stützte sich auf die <c>promptTokens</c> der gerade beantworteten Anfrage:
|
||||
/// Als der Wert vorlag, war der überfüllte Prompt längst abgerechnet, und gekürzt wurde
|
||||
/// erst für den nächsten Schritt. Genau ein Schritt zu spät — und zwar der teuerste.</para>
|
||||
///
|
||||
/// <para>Die Zusage lautet jetzt: <b>Keine Anfrage geht über die Kontextgrenze hinaus.</b>
|
||||
/// Geprüft wird vor dem Senden, mit der Schätzung; die gemessenen Werte ziehen die
|
||||
/// Schätzung hinterher nach.</para>
|
||||
/// </summary>
|
||||
public sealed class ProactiveCompactionTests
|
||||
{
|
||||
private const string AgentModel = "test/model";
|
||||
private const string SummaryModel = "test/summary";
|
||||
|
||||
[Fact]
|
||||
public async Task Keine_Anfrage_geht_ueber_die_Kontextgrenze_hinaus()
|
||||
{
|
||||
var fixture = new EngineFixture();
|
||||
var agent = fixture.AddAgent("agent-a");
|
||||
|
||||
// Schwelle: 1000 geschätzte Tokens.
|
||||
agent.LoopGuard.MaxContextTokens = 2_000;
|
||||
agent.LoopGuard.CompactionThreshold = 0.5;
|
||||
agent.LoopGuard.SummaryModel = SummaryModel;
|
||||
|
||||
fixture.Client.AlwaysRespondsWithText("Verstanden.");
|
||||
|
||||
// Jede Nachricht bringt rund 150 geschaetzte Tokens mit; nach wenigen Runden
|
||||
// waere der Kontext ohne Eingriff ueber der Grenze. Klein genug, dass auch die
|
||||
// sechs geschuetzten Tail-Nachrichten zusammen noch unter die Grenze passen -
|
||||
// sonst koennte selbst eine Kompaktierung sie nicht mehr unterschreiten.
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
await fixture.Engine.ChatAsync(
|
||||
agent, $"Frage {i}: " + new string('y', 600), "test-instance", default);
|
||||
}
|
||||
|
||||
var anfragenAnDenAgenten = fixture.Client.ReceivedRequests
|
||||
.Where(r => r.Model == AgentModel)
|
||||
.ToList();
|
||||
|
||||
anfragenAnDenAgenten.Count.ShouldBe(20);
|
||||
|
||||
// Der eigentliche Punkt: nicht "irgendwann wurde gekürzt", sondern "es ging nie
|
||||
// etwas Überfülltes hinaus". Vorher hing die Prüfung an den promptTokens der
|
||||
// Antwort — beim Fake immer 100 —, kam also nie über die Schwelle und kürzte nie.
|
||||
foreach (var anfrage in anfragenAnDenAgenten)
|
||||
{
|
||||
ContextCompactor.EstimateTokens(anfrage.Messages)
|
||||
.ShouldBeLessThan(agent.LoopGuard.MaxContextTokens);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gekuerzt_wird_mit_dem_guenstigen_Modell_bevor_gesendet_wird()
|
||||
{
|
||||
var fixture = new EngineFixture();
|
||||
var agent = fixture.AddAgent("agent-a");
|
||||
|
||||
agent.LoopGuard.MaxContextTokens = 2_000;
|
||||
agent.LoopGuard.CompactionThreshold = 0.5;
|
||||
agent.LoopGuard.SummaryModel = SummaryModel;
|
||||
|
||||
fixture.Client.AlwaysRespondsWithText("Verstanden.");
|
||||
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
await fixture.Engine.ChatAsync(
|
||||
agent, $"Frage {i}: " + new string('y', 600), "test-instance", default);
|
||||
}
|
||||
|
||||
// Es wurde überhaupt kompaktiert — sonst sagt der Test darüber nichts aus.
|
||||
fixture.Client.ReceivedRequests.ShouldContain(r => r.Model == SummaryModel);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Kalibrierung
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
private static ContextCompactor NeuerKompaktierer()
|
||||
=> new(new FakeChatClient(), TestLogging.Factory);
|
||||
|
||||
[Fact]
|
||||
public void Ohne_Messwerte_gilt_die_rohe_Schaetzung()
|
||||
{
|
||||
var messages = Conversation.Start().Repeat(4).Build();
|
||||
|
||||
NeuerKompaktierer().EstimateFor("m", messages)
|
||||
.ShouldBe(ContextCompactor.EstimateTokens(messages));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Eine_unterschaetzte_Anfrage_hebt_die_Schaetzung_an()
|
||||
{
|
||||
var messages = Conversation.Start().Repeat(4).Build();
|
||||
var roh = ContextCompactor.EstimateTokens(messages);
|
||||
|
||||
var compactor = NeuerKompaktierer();
|
||||
|
||||
// Die API berechnet durchweg doppelt so viel wie geschätzt.
|
||||
for (var i = 0; i < 20; i++)
|
||||
compactor.Calibrate("m", 1_000, 2_000);
|
||||
|
||||
compactor.EstimateFor("m", messages).ShouldBeGreaterThan(roh);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ein_einzelner_Ausreisser_kippt_die_Schwelle_nicht()
|
||||
{
|
||||
var messages = Conversation.Start().Repeat(4).Build();
|
||||
var roh = ContextCompactor.EstimateTokens(messages);
|
||||
|
||||
var compactor = NeuerKompaktierer();
|
||||
compactor.Calibrate("m", 1_000, 4_000); // einmal weit daneben
|
||||
|
||||
// Ein Messwert verschiebt den Faktor nur um sein Gewicht, nicht auf seinen Wert.
|
||||
compactor.EstimateFor("m", messages).ShouldBeLessThan(roh * 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ein_absurder_Messwert_wird_gedeckelt()
|
||||
{
|
||||
var messages = Conversation.Start().Repeat(4).Build();
|
||||
var roh = ContextCompactor.EstimateTokens(messages);
|
||||
|
||||
var compactor = NeuerKompaktierer();
|
||||
for (var i = 0; i < 50; i++)
|
||||
compactor.Calibrate("m", 1, 1_000_000);
|
||||
|
||||
// Höchstens das Vierfache — was darüber läge, ist kein Kalibrierungsfall mehr.
|
||||
compactor.EstimateFor("m", messages).ShouldBeLessThanOrEqualTo(roh * 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Jedes_Modell_wird_fuer_sich_kalibriert()
|
||||
{
|
||||
// Verschiedene Tokenizer zerlegen denselben Text verschieden — ein gemeinsamer
|
||||
// Faktor wäre der Mittelwert von etwas, das nichts miteinander zu tun hat.
|
||||
var messages = Conversation.Start().Repeat(4).Build();
|
||||
var roh = ContextCompactor.EstimateTokens(messages);
|
||||
|
||||
var compactor = NeuerKompaktierer();
|
||||
for (var i = 0; i < 20; i++)
|
||||
compactor.Calibrate("gross", 1_000, 3_000);
|
||||
|
||||
compactor.EstimateFor("gross", messages).ShouldBeGreaterThan(roh);
|
||||
compactor.EstimateFor("unberuehrt", messages).ShouldBe(roh);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unbrauchbare_Messwerte_werden_verworfen()
|
||||
{
|
||||
var messages = Conversation.Start().Repeat(4).Build();
|
||||
var roh = ContextCompactor.EstimateTokens(messages);
|
||||
|
||||
var compactor = NeuerKompaktierer();
|
||||
compactor.Calibrate("m", 0, 5_000); // ohne Schätzung kein Verhältnis
|
||||
compactor.Calibrate("m", 1_000, 0); // ohne Messwert auch nicht
|
||||
|
||||
compactor.EstimateFor("m", messages).ShouldBe(roh);
|
||||
}
|
||||
}
|
||||
@@ -100,14 +100,14 @@ public sealed class TokenAccountingTests
|
||||
|
||||
var guard = new LoopGuardConfig
|
||||
{
|
||||
MaxContextTokens = 1_000,
|
||||
MaxContextTokens = 100,
|
||||
CompactionThreshold = 0.5,
|
||||
SummaryModel = "google/gemini-2.5-flash"
|
||||
};
|
||||
|
||||
var messages = Conversation.Start().Repeat(10).Build();
|
||||
|
||||
await compactor.CompactIfNeededAsync(messages, 50_000, guard, "anthropic/claude-opus-4", default);
|
||||
await compactor.CompactIfNeededAsync(messages, guard, "anthropic/claude-opus-4", default);
|
||||
|
||||
client.ReceivedRequests.ShouldNotBeEmpty();
|
||||
client.ReceivedRequests[0].Model.ShouldBe("google/gemini-2.5-flash",
|
||||
@@ -122,14 +122,14 @@ public sealed class TokenAccountingTests
|
||||
|
||||
var guard = new LoopGuardConfig
|
||||
{
|
||||
MaxContextTokens = 1_000,
|
||||
MaxContextTokens = 100,
|
||||
CompactionThreshold = 0.5,
|
||||
SummaryModel = ""
|
||||
};
|
||||
|
||||
var messages = Conversation.Start().Repeat(10).Build();
|
||||
|
||||
await compactor.CompactIfNeededAsync(messages, 50_000, guard, "anthropic/claude-opus-4", default);
|
||||
await compactor.CompactIfNeededAsync(messages, guard, "anthropic/claude-opus-4", default);
|
||||
|
||||
client.ReceivedRequests[0].Model.ShouldBe("anthropic/claude-opus-4");
|
||||
}
|
||||
@@ -146,14 +146,14 @@ public sealed class TokenAccountingTests
|
||||
var client = new FakeChatClient().AlwaysRespondsWithText("- Zusammenfassung.");
|
||||
var compactor = new ContextCompactor(client, TestLogging.Factory);
|
||||
|
||||
var guard = new LoopGuardConfig { MaxContextTokens = 1_000, CompactionThreshold = 0.5 };
|
||||
var guard = new LoopGuardConfig { MaxContextTokens = 100, CompactionThreshold = 0.5 };
|
||||
|
||||
var messages = Conversation.Start()
|
||||
.Repeat(8)
|
||||
.User("EINZIGARTIGE-LETZTE-NACHRICHT")
|
||||
.Build();
|
||||
|
||||
await compactor.CompactIfNeededAsync(messages, 50_000, guard, "test/model", default);
|
||||
await compactor.CompactIfNeededAsync(messages, guard, "test/model", default);
|
||||
|
||||
var summaryPrompt = client.ReceivedRequests[0].Messages.Last().Content!;
|
||||
summaryPrompt.ShouldNotContain("EINZIGARTIGE-LETZTE-NACHRICHT");
|
||||
|
||||
Reference in New Issue
Block a user