202 lines
8.6 KiB
C#
202 lines
8.6 KiB
C#
using Microsoft.Extensions.AI;
|
|
using System.Text.RegularExpressions;
|
|
using Spectre.Console;
|
|
|
|
namespace AnchorCli;
|
|
|
|
/// <summary>
|
|
/// Compacts the chat history when context usage grows too large.
|
|
/// Asks the same model to summarize the conversation, then replaces
|
|
/// the history with [system prompt, summary, last N user/assistant turns].
|
|
/// </summary>
|
|
internal sealed partial class ContextCompactor(IChatClient client)
|
|
{
|
|
/// <summary>Number of recent user+assistant turn pairs to keep verbatim.</summary>
|
|
private const int KeepRecentTurns = 2;
|
|
|
|
/// <summary>Minimum result length to consider for compaction.</summary>
|
|
private const int MinResultLength = 300;
|
|
|
|
/// <summary>Matches hashline-encoded output: "lineNumber:hash|content"</summary>
|
|
private static readonly Regex HashlinePattern =
|
|
MyRegex();
|
|
|
|
private readonly IChatClient _client = client;
|
|
|
|
/// <summary>
|
|
/// Compacts large hashline-encoded tool results from previous turns.
|
|
/// This is the cheapest and most impactful optimization — no LLM call needed.
|
|
/// A 300-line ReadFile result (~10K tokens) becomes a one-line note (~20 tokens).
|
|
/// </summary>
|
|
/// <param name="history">The chat history to compact in-place.</param>
|
|
/// <returns>Number of tool results that were compacted.</returns>
|
|
public static int CompactStaleToolResults(List<ChatMessage> history)
|
|
{
|
|
int compacted = 0;
|
|
int userTurnsSeen = 0;
|
|
Dictionary<string, int> filesRead = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
// Walk backwards: index 0 is system prompt, so we stop at 1.
|
|
for (int i = history.Count - 1; i >= 1; i--)
|
|
{
|
|
var msg = history[i];
|
|
|
|
if (msg.Role == ChatRole.User)
|
|
{
|
|
userTurnsSeen++;
|
|
}
|
|
else if (msg.Role == ChatRole.Assistant)
|
|
{
|
|
// Find all FunctionCalls in this assistant message to map call IDs to file paths
|
|
var calls = msg.Contents.OfType<FunctionCallContent>().ToList();
|
|
|
|
// If the very next message is a System/Tool role containing FunctionResults
|
|
if (i + 1 < history.Count && history[i + 1].Role == ChatRole.Tool)
|
|
{
|
|
var resultMsg = history[i + 1];
|
|
foreach (var content in resultMsg.Contents)
|
|
{
|
|
if (content is FunctionResultContent frc &&
|
|
frc.Result is string resultStr &&
|
|
resultStr.Length >= MinResultLength &&
|
|
HashlinePattern.IsMatch(resultStr))
|
|
{
|
|
// Find the corresponding function call to check its name/arguments
|
|
var call = calls.FirstOrDefault(c => c.CallId == frc.CallId);
|
|
if (call?.Name == "ReadFile" && call.Arguments != null && call.Arguments.TryGetValue("path", out var pathObj))
|
|
{
|
|
string filePath = pathObj?.ToString() ?? "";
|
|
|
|
bool shouldRedact = false;
|
|
string reason = "";
|
|
|
|
// Rule 1: Deduplication. If we have already seen this file in a newer message (since we are walking backward), redact this one.
|
|
if (filesRead.TryGetValue(filePath, out int count) && count >= 3)
|
|
{
|
|
shouldRedact = true;
|
|
reason = "deduplication — you read this file 5 or more times later";
|
|
}
|
|
// Rule 2: TTL. If this was read 2 or more user turns ago, redact it.
|
|
else if (userTurnsSeen >= 2)
|
|
{
|
|
shouldRedact = true;
|
|
reason = "expired — read over 2 turns ago";
|
|
}
|
|
|
|
if (shouldRedact)
|
|
{
|
|
int lineCount = resultStr.Count(c => c == '\n');
|
|
frc.Result = $"[File content: {lineCount} lines redacted for {reason}. Re-read the file if you need fresh anchors.]";
|
|
compacted++;
|
|
}
|
|
else
|
|
{
|
|
// Keep it, but mark that we've seen it so older reads of the same file are redacted.
|
|
filesRead[filePath] = filesRead.GetValueOrDefault(filePath) + 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return compacted;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Compacts the history in-place via LLM summarization. Returns true if compaction was performed.
|
|
/// </summary>
|
|
public async Task<bool> TryCompactAsync(
|
|
List<ChatMessage> history,
|
|
CancellationToken ct = default)
|
|
{
|
|
// Need at least: system + some conversation to compact
|
|
if (history.Count < 5)
|
|
return false;
|
|
|
|
// Split: system prompt (index 0) | middle (compactable) | tail (keep verbatim)
|
|
var systemMsg = history[0];
|
|
|
|
// Find the cut point: keep the last N user+assistant pairs
|
|
int keepFromIndex = FindKeepIndex(history);
|
|
if (keepFromIndex <= 1)
|
|
return false; // Nothing to compact
|
|
|
|
// Extract the middle section to summarize
|
|
var toSummarize = history.Skip(1).Take(keepFromIndex - 1).ToList();
|
|
var tail = history.Skip(keepFromIndex).ToList();
|
|
|
|
// Build a summarization prompt
|
|
var summaryMessages = new List<ChatMessage>
|
|
{
|
|
new(ChatRole.System, """
|
|
You are a conversation summarizer. Summarize the following coding conversation
|
|
between a user and an AI assistant. Focus on:
|
|
- What files were read, created, or modified (and their paths)
|
|
- What changes were made and why
|
|
- Any decisions or preferences expressed by the user
|
|
- Current state of the work (what's done, what's pending)
|
|
Be concise but preserve all actionable context. Output a single summary paragraph.
|
|
Do NOT include any hashline anchors (lineNumber:hash|) in your summary.
|
|
""")
|
|
};
|
|
summaryMessages.AddRange(toSummarize);
|
|
summaryMessages.Add(new(ChatRole.User,
|
|
"Summarize the above conversation concisely, preserving all important context for continuing the work."));
|
|
|
|
string summary;
|
|
try
|
|
{
|
|
var response = await _client.GetResponseAsync(summaryMessages, cancellationToken: ct);
|
|
summary = response.Text ?? "(summary unavailable)";
|
|
}
|
|
catch
|
|
{
|
|
// If summarization fails, don't compact
|
|
return false;
|
|
}
|
|
|
|
// Rebuild history: system + summary + recent turns
|
|
history.Clear();
|
|
history.Add(systemMsg);
|
|
history.Add(new(ChatRole.User,
|
|
"[CONTEXT COMPACTED — The following is a summary of the earlier conversation]\n" + summary));
|
|
history.Add(new(ChatRole.Assistant,
|
|
"Understood, I have the context from our earlier conversation. I'll continue from where we left off."));
|
|
history.AddRange(tail);
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finds the index from which to keep messages verbatim (the last N turn pairs).
|
|
/// </summary>
|
|
private static int FindKeepIndex(List<ChatMessage> history)
|
|
{
|
|
int pairsFound = 0;
|
|
int idx = history.Count - 1;
|
|
|
|
while (idx > 0 && pairsFound < KeepRecentTurns)
|
|
{
|
|
// Walk backwards looking for user+assistant pairs
|
|
if (history[idx].Role == ChatRole.Assistant && idx > 1 &&
|
|
history[idx - 1].Role == ChatRole.User)
|
|
{
|
|
pairsFound++;
|
|
idx -= 2;
|
|
}
|
|
else
|
|
{
|
|
idx--;
|
|
}
|
|
}
|
|
|
|
// idx+1 is the first message to keep
|
|
return Math.Max(1, idx + 1);
|
|
}
|
|
|
|
[GeneratedRegex(@"^\d+:[0-9a-fA-F]{2}\|", RegexOptions.Multiline | RegexOptions.Compiled)]
|
|
private static partial Regex MyRegex();
|
|
}
|