T1 — Prompt-Caching. Bisher wurde bei jedem Schritt eines Runs der komplette Prompt neu berechnet, inklusive Tool-Definitionen und System-Prompt, die sich nie aendern. Bei zehn Schritten und einem 15k-Praefix sind das 150.000 statt 15.000 Eingabe-Tokens. ChatMessage bekommt dafuer einen eigenen JsonConverter: Der Inhalt geht weiterhin als String raus, bei gesetztem CacheBreakpoint jedoch als Blockarray mit cache_control. Beim Lesen werden beide Formate akzeptiert, damit bestehende ChatContext.json weiter geladen werden koennen. PromptCache setzt zwei Breakpoints: einen auf den System-Prompt (deckt Tool-Definitionen und System-Prompt ab) und einen rollierenden auf die letzte Nachricht mit Inhalt. Vorherige Markierungen werden vorher entfernt, damit sie sich nicht ansammeln. Aktivierung ueber promptCaching: auto (Default, aktiv fuer Modelle mit Unterstuetzung), on oder off. Der wichtigste Test dazu prueft die Praefix-Stabilitaet: Der System-Prompt muss ueber alle Schritte zeichengleich serialisiert werden. Ein einziger Zeitstempel darin wuerde den Cache still verwerfen — die Kosten blieben unveraendert, ohne dass es irgendwo auffiele. T9 — Usage liest prompt_tokens_details.cached_tokens; die Zahl wird bis in AgentRunResult durchgereicht. Ohne sie liesse sich die Wirkung nicht belegen. T2 — Tool-Ergebnisse werden jetzt zentral in ExecuteToolCallAsync gekappt (maxToolResultChars, Default 16.000). Bisher konnte ein einzelner WebFetch mit dem 512-KB-Standardlimit rund 130.000 Tokens in EINER Antwort erzeugen; die Compaction griff erst danach, bezahlt war der Request laengst. T3 — Die Zusammenfassung beim Kompaktieren laeuft ueber ein konfigurierbares summaryModel (Default gemini-2.5-flash) statt ueber das teure Agentenmodell. B4 — AgentRunResult fuehrt Prompt- und Completion-Tokens getrennt; die Kostenanzeige schaetzte bisher 50/50, real liegt das Verhaeltnis eher bei 95:5. Die veraltete Preistabelle bleibt offen. Neue Einstellungen sind im PropertyGrid sichtbar und werden vom AgentEditor bei neuen Agenten mitgeschrieben. Alle 91 Tests gruen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
236 lines
8.3 KiB
C#
236 lines
8.3 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 ...]";
|
|
|
|
public ContextCompactor(IChatCompletionClient client, ILoggerFactory loggerFactory)
|
|
{
|
|
_client = client;
|
|
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.ContextCompactor");
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
public async Task<bool> CompactIfNeededAsync(
|
|
List<ChatMessage> messages,
|
|
int lastPromptTokens,
|
|
LoopGuardConfig guard,
|
|
string model,
|
|
CancellationToken ct)
|
|
{
|
|
var contextTokens = lastPromptTokens > 0
|
|
? lastPromptTokens
|
|
: EstimateTokens(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}";
|
|
}
|
|
}
|