Files
ClawdDotNet/src/ClawdDotNet.Core/Engine/ContextCompactor.cs
T
RichardandClaude Opus 5 cffd6f4b77 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>
2026-09-03 19:04:48 +02:00

291 lines
11 KiB
C#

using ClawdDotNet.Core.Api;
using ClawdDotNet.Core.Api.Models;
using ClawdDotNet.Core.Config;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Engine;
public sealed class ContextCompactor
{
private readonly IChatCompletionClient _client;
private readonly ILogger _logger;
private const int ProtectedTailMessages = 6;
private const int MaxToolResultChars = 2000;
private const string TruncatedMarker = "\n\n[... Ergebnis gekürzt ...]";
/// <summary>
/// Wie stark ein einzelner Messwert den Faktor verschiebt. Klein genug, dass ein
/// Ausreißer die Schwelle nicht kippt, groß genug, dass ein Modellwechsel nach
/// wenigen Aufrufen ankommt.
/// </summary>
private const double CalibrationWeight = 0.25;
/// <summary>
/// Je Modell: um welchen Faktor die rohe Schätzung danebenlag. 1,0 heißt „noch nichts
/// gemessen". Modellweise, weil verschiedene Tokenizer denselben Text verschieden
/// zerlegen — ein gemeinsamer Faktor wäre der Mittelwert von etwas, das nichts
/// miteinander zu tun hat.
/// </summary>
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, double> _calibration = new();
public ContextCompactor(IChatCompletionClient client, ILoggerFactory loggerFactory)
{
_client = client;
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.ContextCompactor");
}
/// <summary>
/// Die rohe Schätzung: vier Zeichen je Token. Grob, aber ohne Netzaufruf und ohne
/// Kenntnis des Tokenizers — und darauf kommt es an, weil sie <b>vor</b> dem Senden
/// gebraucht wird. <see cref="EstimateFor"/> zieht die gemessene Abweichung nach.
/// </summary>
public static int EstimateTokens(List<ChatMessage> messages)
{
var totalChars = 0;
foreach (var msg in messages)
{
totalChars += msg.Content?.Length ?? 0;
totalChars += msg.Role.Length + 10;
if (msg.ToolCalls is not null)
{
foreach (var tc in msg.ToolCalls)
totalChars += tc.Function.Name.Length + tc.Function.Arguments.Length + 20;
}
}
return totalChars / 4;
}
/// <summary>
/// Die Schätzung für ein bestimmtes Modell — die rohe Rechnung, korrigiert um das,
/// was bei diesem Modell bisher tatsächlich gemessen wurde.
/// </summary>
public int EstimateFor(string model, List<ChatMessage> messages)
=> (int)(EstimateTokens(messages) * _calibration.GetValueOrDefault(model, 1.0));
/// <summary>
/// Meldet, wie groß der Prompt wirklich war, den eine Schätzung vorhergesagt hat.
///
/// <para>Vier Zeichen je Token stimmt für englische Fließtexte ungefähr und liegt
/// bei deutschem Text, JSON und Code daneben — je nach Modell in beide Richtungen.
/// Der Faktor zieht die Schätzung dorthin, wo dieses Modell tatsächlich liegt.</para>
///
/// <para>Gleitender Mittelwert statt letztem Messwert: Ein einzelner Ausreißer soll
/// die Schwelle nicht verschieben. Begrenzt auf das Halbe bis Vierfache — was
/// darüber hinausginge, ist kein Kalibrierungsfall mehr, sondern ein Fehler
/// woanders, und den soll die Schätzung nicht kaschieren.</para>
/// </summary>
public void Calibrate(string model, int estimatedTokens, int actualPromptTokens)
{
if (estimatedTokens <= 0 || actualPromptTokens <= 0) return;
var gemessen = Math.Clamp((double)actualPromptTokens / estimatedTokens, 0.5, 4.0);
// Auch der erste Messwert zählt nur mit seinem Gewicht — Ausgangspunkt ist die
// rohe Schätzung (Faktor 1,0). Setzte der erste Wert den Faktor voll, könnte ein
// einzelner Ausreißer die Schwelle sofort verschieben, und genau das soll der
// gleitende Mittelwert verhindern.
static double Naechster(double bisher, double gemessen)
=> bisher * (1 - CalibrationWeight) + gemessen * CalibrationWeight;
_calibration.AddOrUpdate(
model,
Naechster(1.0, gemessen),
(_, bisher) => Naechster(bisher, gemessen));
}
public async Task<bool> CompactIfNeededAsync(
List<ChatMessage> messages,
LoopGuardConfig guard,
string model,
CancellationToken ct)
{
var contextTokens = EstimateFor(model, messages);
var threshold = (int)(guard.MaxContextTokens * guard.CompactionThreshold);
if (contextTokens < threshold)
return false;
_logger.LogInformation(
"Context kompaktierung gestartet: {Tokens} Tokens (Schwelle: {Threshold})",
contextTokens, threshold);
// Stufe 1: Tool-Results kürzen
var pruned = PruneToolResults(messages);
if (pruned)
{
var afterPrune = EstimateTokens(messages);
_logger.LogInformation("Stufe 1 (Tool-Pruning): {Before} → {After} geschätzte Tokens",
contextTokens, afterPrune);
if (afterPrune < threshold)
return true;
}
// Stufe 2: Auto-Compaction via LLM — bewusst mit dem günstigen Modell.
var summaryModel = string.IsNullOrWhiteSpace(guard.SummaryModel)
? model
: guard.SummaryModel;
await CompactViaLlmAsync(messages, summaryModel, ct);
return true;
}
private bool PruneToolResults(List<ChatMessage> messages)
{
var pruned = false;
var protectedStart = Math.Max(0, messages.Count - ProtectedTailMessages);
for (var i = 0; i < protectedStart; i++)
{
var msg = messages[i];
if (msg.Role != "tool" || msg.Content is null)
continue;
if (msg.Content.Length <= MaxToolResultChars)
continue;
msg.Content = msg.Content[..MaxToolResultChars] + TruncatedMarker;
pruned = true;
}
return pruned;
}
/// <summary>
/// Sucht einen Index, an dem der Kontext geteilt werden darf, ohne eine Tool-Sequenz
/// zu zerreißen.
///
/// Eine <c>tool</c>-Nachricht darf nie am Anfang des behaltenen Teils stehen — ihr
/// <c>assistant</c>-Aufruf stünde sonst nicht mehr davor und die API lehnt die
/// gesamte Anfrage mit HTTP 400 ab. Deshalb wandert die Grenze rückwärts bis zum
/// Beginn des Blocks, in den sie fällt.
/// </summary>
private static int FindSafeTailStart(List<ChatMessage> messages, int desiredStart, int minIndex)
{
var i = Math.Clamp(desiredStart, minIndex, messages.Count);
// Rückwärts, bis der Anfang keine verwaiste Tool-Antwort mehr ist.
while (i > minIndex && i < messages.Count && messages[i].Role == "tool")
i--;
return i;
}
private async Task CompactViaLlmAsync(
List<ChatMessage> messages, string model, CancellationToken ct)
{
var systemMsg = messages.FirstOrDefault(m => m.Role == "system");
// Der System-Prompt wird separat behandelt und darf nie Teil des Tails werden,
// sonst landet er doppelt im Kontext.
var firstNonSystem = systemMsg is null ? 0 : messages.IndexOf(systemMsg) + 1;
var tailStart = FindSafeTailStart(
messages,
messages.Count - ProtectedTailMessages,
firstNonSystem);
// Wenn vor dem geschützten Ende nichts Nennenswertes liegt, gibt es nichts
// zusammenzufassen — eine Kompaktierung würde den Kontext sogar vergrößern.
if (tailStart - firstNonSystem < 2)
{
_logger.LogInformation(
"Compaction übersprungen: nur {Count} Nachricht(en) vor dem geschützten Ende",
tailStart - firstNonSystem);
return;
}
// Nur den Teil zusammenfassen, der tatsächlich wegfällt — der Tail bleibt
// ohnehin wörtlich erhalten und müsste sonst doppelt bezahlt werden.
var conversationParts = messages
.Take(tailStart)
.Skip(firstNonSystem)
.Select(FormatMessageForSummary);
var conversationText = string.Join("\n", conversationParts);
// Auf max 30k Zeichen begrenzen für den Summarization-Call
if (conversationText.Length > 30_000)
conversationText = conversationText[..30_000] + "\n[... weitere Nachrichten ausgelassen ...]";
var summaryRequest = new ChatRequest
{
Model = model,
Messages =
[
ChatMessage.System(
"Du bist ein Konversations-Zusammenfasser. Erstelle eine präzise Zusammenfassung " +
"der bisherigen Konversation. Behalte alle wichtigen Fakten, Entscheidungen, " +
"Ergebnisse von Tool-Aufrufen und den aktuellen Arbeitsstand bei. " +
"Schreibe in der dritten Person. Format: Strukturierte Stichpunkte."),
ChatMessage.User(
"Fasse die folgende Konversation zusammen. Behalte alle wichtigen Details:\n\n" +
conversationText)
]
};
try
{
var response = await _client.CompleteAsync(summaryRequest, ct);
var summary = response.Choices.FirstOrDefault()?.Message?.Content;
if (string.IsNullOrWhiteSpace(summary))
{
_logger.LogWarning("Compaction: Keine Zusammenfassung erhalten");
return;
}
// Nachrichten ersetzen: System-Prompt + Zusammenfassung + geschützte letzte Nachrichten.
// tailStart liegt garantiert auf einer Blockgrenze und hinter dem System-Prompt.
var tail = messages.Skip(tailStart).ToList();
messages.Clear();
if (systemMsg is not null)
messages.Add(systemMsg);
messages.Add(ChatMessage.User(
"[Zusammenfassung der bisherigen Konversation]\n\n" + summary));
messages.Add(ChatMessage.Assistant(
"Verstanden. Ich habe den Kontext der bisherigen Konversation erfasst und arbeite weiter."));
messages.AddRange(tail);
_logger.LogInformation(
"Stufe 2 (Auto-Compaction): Konversation auf {Count} Nachrichten kompaktiert",
messages.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Compaction fehlgeschlagen");
}
}
private static string FormatMessageForSummary(ChatMessage msg)
{
if (msg.Role == "tool")
{
var preview = msg.Content?.Length > 200
? msg.Content[..200] + "..."
: msg.Content;
return $"[Tool-Result ({msg.ToolCallId})]: {preview}";
}
if (msg.ToolCalls is { Count: > 0 })
{
var calls = string.Join(", ",
msg.ToolCalls.Select(tc => $"{tc.Function.Name}({tc.Function.Arguments[..Math.Min(100, tc.Function.Arguments.Length)]})"));
return $"[Assistant → Tool-Calls]: {calls}";
}
var content = msg.Content?.Length > 500
? msg.Content[..500] + "..."
: msg.Content;
return $"[{msg.Role}]: {content}";
}
}