initial commit
This commit is contained in:
157
ContextCompactor.cs
Normal file
157
ContextCompactor.cs
Normal file
@@ -0,0 +1,157 @@
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user