Mit Roslyn umbenannt (Verweise folgen, Zeichenketten bleiben unberuehrt). Nicht umbenannt, weil gespeichert oder ueber die API gebunden (Stufe 4e): Properties, Record-Parameter, Enum-Member sowie die Parameter der Endpunkt-Handler (Routen-/Query-Bindung ueber den Namen). Die JSON-Felder anonymer Objekte bleiben ausdruecklich benannt (JsonFilter: pfad). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
114 lines
4.4 KiB
C#
114 lines
4.4 KiB
C#
using ClawdDotNet.Core.Api.Models;
|
|
using ClawdDotNet.Core.Config;
|
|
using ClawdDotNet.Core.Engine;
|
|
using ClawdDotNet.Core.Tests.Infrastructure;
|
|
using Shouldly;
|
|
|
|
namespace ClawdDotNet.Core.Tests.Engine;
|
|
|
|
/// <summary>
|
|
/// #605: In continuous operation compaction started at every step and stayed ineffective -
|
|
/// stage 1 checked with the raw instead of the calibrated estimate, compacted only to just
|
|
/// below the threshold, and the summarization call was not booked.
|
|
/// </summary>
|
|
public sealed class CompactionMarginTests
|
|
{
|
|
private const string Model = "test/model";
|
|
|
|
private static (ContextCompactor Compactor, FakeChatClient Client) Create()
|
|
{
|
|
var client = new FakeChatClient().AlwaysRespondsWithText("- Der Agent hat Daten geprueft.");
|
|
return (new ContextCompactor(client, TestLogging.Factory), client);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The raw estimate as it would look after stage 1 - rebuilt in order to choose thresholds
|
|
/// at which the old and new check decide differently. Values as in the
|
|
/// ContextCompactor: the last 6 messages protected, tool results at 2,000 characters.
|
|
/// </summary>
|
|
private static int RawAfterStage1(List<ChatMessage> messages)
|
|
{
|
|
var copy = messages
|
|
.Select(m => new ChatMessage { Role = m.Role, Content = m.Content, ToolCallId = m.ToolCallId, ToolCalls = m.ToolCalls })
|
|
.ToList();
|
|
|
|
var protection = Math.Max(0, copy.Count - 6);
|
|
for (var i = 0; i < protection; i++)
|
|
{
|
|
if (copy[i].Role == "tool" && copy[i].Content is { Length: > 2_000 } content)
|
|
copy[i].Content = content[..2_000] + "\n\n[... Ergebnis gekürzt ...]";
|
|
}
|
|
|
|
return ContextCompactor.EstimateTokens(copy);
|
|
}
|
|
|
|
private static List<ChatMessage> LongHistory()
|
|
=> Conversation.Start().Repeat(times: 6, toolCallsPerCycle: 1, resultLength: 8_000).Build();
|
|
|
|
[Fact]
|
|
public async Task After_stage_1_the_calibrated_estimate_decides()
|
|
{
|
|
var (compactor, client) = Create();
|
|
|
|
// This model counts about three times as many tokens as chars/4 suggests.
|
|
for (var i = 0; i < 12; i++)
|
|
compactor.Calibrate(Model, 1_000, 3_000);
|
|
|
|
var messages = LongHistory();
|
|
var rawAfter = RawAfterStage1(messages);
|
|
|
|
// Raw, stage 1 lies below the threshold, calibrated clearly above it.
|
|
var threshold = (int)(rawAfter * 1.5);
|
|
var guard = new LoopGuardConfig { MaxContextTokens = threshold * 2, CompactionThreshold = 0.5 };
|
|
|
|
await compactor.CompactIfNeededAsync(messages, guard, Model, default);
|
|
|
|
client.CallCount.ShouldBe(1, "die rohe Zahl meldete faelschlich 'unter der Schwelle' und Stufe 2 lief nie");
|
|
ContextInvariants.AssertValid(messages);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Just_below_the_threshold_is_not_enough_compaction_keeps_a_margin()
|
|
{
|
|
var (compactor, client) = Create();
|
|
var messages = LongHistory();
|
|
var rawAfter = RawAfterStage1(messages);
|
|
|
|
// Stage 1 brings it just below the threshold (90 %), but not below the target (55 %).
|
|
var threshold = (int)(rawAfter * 1.2);
|
|
var guard = new LoopGuardConfig { MaxContextTokens = (int)(threshold / 0.9), CompactionThreshold = 0.9 };
|
|
|
|
await compactor.CompactIfNeededAsync(messages, guard, Model, default);
|
|
|
|
client.CallCount.ShouldBe(1, "knapp unter der Schwelle waere beim naechsten Schritt wieder darueber");
|
|
ContextInvariants.AssertValid(messages);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_summary_call_is_booked()
|
|
{
|
|
var (compactor, _) = Create();
|
|
var booked = new List<Usage>();
|
|
var messages = Conversation.Start().Repeat(15).Build();
|
|
|
|
await compactor.CompactIfNeededAsync(
|
|
messages, new LoopGuardConfig { MaxContextTokens = 1_000, CompactionThreshold = 0.5 }, Model, default, booked.Add);
|
|
|
|
booked.ShouldHaveSingleItem().PromptTokens.ShouldBe(100);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Below_the_threshold_nothing_is_booked()
|
|
{
|
|
var (compactor, client) = Create();
|
|
var booked = new List<Usage>();
|
|
|
|
await compactor.CompactIfNeededAsync(
|
|
Conversation.Start().Repeat(2).Build(),
|
|
new LoopGuardConfig { MaxContextTokens = 10_000_000, CompactionThreshold = 0.9 }, Model, default, booked.Add);
|
|
|
|
booked.ShouldBeEmpty();
|
|
client.CallCount.ShouldBe(0);
|
|
}
|
|
}
|