158 lines
6.0 KiB
C#
158 lines
6.0 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>
|
|
/// <param name="currentTurnStartIndex">
|
|
/// Index of the first message added during the current turn.
|
|
/// Messages before this index are from previous turns and eligible for compaction.
|
|
/// </param>
|
|
/// <returns>Number of tool results that were compacted.</returns>
|
|
public static int CompactStaleToolResults(List<ChatMessage> history, int currentTurnStartIndex)
|
|
{
|
|
int compacted = 0;
|
|
|
|
for (int i = 0; i < currentTurnStartIndex && i < history.Count; i++)
|
|
{
|
|
var msg = history[i];
|
|
|
|
foreach (var content in msg.Contents)
|
|
{
|
|
if (content is FunctionResultContent frc &&
|
|
frc.Result is string resultStr &&
|
|
resultStr.Length >= MinResultLength &&
|
|
HashlinePattern.IsMatch(resultStr))
|
|
{
|
|
// Count lines and replace with compact summary
|
|
int lineCount = resultStr.Count(c => c == '\n');
|
|
frc.Result = $"[File content: {lineCount} lines — already consumed. Re-read the file if you need fresh anchors.]";
|
|
compacted++;
|
|
}
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|