feat: Add file editing tools with anchor validation and improve context compaction by redacting stale ReadFile results based on deduplication and time-to-live.
This commit is contained in:
@@ -34,25 +34,73 @@ internal sealed partial class ContextCompactor(IChatClient client)
|
||||
/// 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)
|
||||
public static int CompactStaleToolResults(List<ChatMessage> history)
|
||||
{
|
||||
int compacted = 0;
|
||||
int userTurnsSeen = 0;
|
||||
var filesRead = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
for (int i = 0; i < currentTurnStartIndex && i < history.Count; i++)
|
||||
// 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];
|
||||
|
||||
foreach (var content in msg.Contents)
|
||||
if (msg.Role == ChatRole.User)
|
||||
{
|
||||
if (content is FunctionResultContent frc &&
|
||||
frc.Result is string resultStr &&
|
||||
resultStr.Length >= MinResultLength &&
|
||||
HashlinePattern.IsMatch(resultStr))
|
||||
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)
|
||||
{
|
||||
// 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++;
|
||||
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.Contains(filePath))
|
||||
{
|
||||
shouldRedact = true;
|
||||
reason = "deduplication — you read this file again 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.Add(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ internal sealed class ReplLoop
|
||||
|
||||
_session.History.Add(new ChatMessage(ChatRole.Assistant, fullResponse));
|
||||
|
||||
int compactedResults = ContextCompactor.CompactStaleToolResults(_session.History, turnStartIndex);
|
||||
int compactedResults = ContextCompactor.CompactStaleToolResults(_session.History);
|
||||
if (compactedResults > 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine(
|
||||
|
||||
@@ -52,12 +52,12 @@ internal static partial class EditTools
|
||||
return result;
|
||||
}
|
||||
|
||||
[Description("Replace a range of lines in a file, identified by Hashline anchors. Both the line number and hash must match the current file state.")]
|
||||
[Description("Replace a range of lines. Both line number and hash in the line:hash anchor must match.")]
|
||||
public static string ReplaceLines(
|
||||
[Description("Path to the file.")] string path,
|
||||
[Description("line:hash anchor of the first line to replace (e.g. '5:a3'). Both the line number AND hash must match.")] string startAnchor,
|
||||
[Description("line:hash anchor of the last line to replace (e.g. '7:0e'). Use the same as startAnchor to replace a single line.")] string endAnchor,
|
||||
[Description("New lines to insert in place of the replaced range. Each element becomes one line in the file. IMPORTANT: Write raw source code only. Do NOT include 'lineNumber:hash|' prefixes — those are display-only metadata from ReadFile, not part of the actual file content.")] string[] newLines)
|
||||
[Description("First line's line:hash anchor (e.g. '5:a3').")] string startAnchor,
|
||||
[Description("Last line's line:hash anchor. Use same as startAnchor for a single line.")] string endAnchor,
|
||||
[Description("Raw source code to insert. Do NOT include 'lineNumber:hash|' prefixes.")] string[] newLines)
|
||||
{
|
||||
newLines = SanitizeNewLines(newLines);
|
||||
path = FileTools.ResolvePath(path);
|
||||
@@ -90,11 +90,11 @@ internal static partial class EditTools
|
||||
}
|
||||
}
|
||||
|
||||
[Description("Insert new lines immediately after the line identified by a Hashline anchor.")]
|
||||
[Description("Insert lines after the specified line:hash anchor.")]
|
||||
public static string InsertAfter(
|
||||
[Description("Path to the file.")] string path,
|
||||
[Description("line:hash anchor of the line to insert after (e.g. '3:0e'). Both the line number AND hash must match.")] string anchor,
|
||||
[Description("Lines to insert after the anchor line. Each element becomes one line in the file. IMPORTANT: Write raw source code only. Do NOT include 'lineNumber:hash|' prefixes — those are display-only metadata from ReadFile, not part of the actual file content.")] string[] newLines)
|
||||
[Description("line:hash anchor to insert after (e.g. '3:0e').")] string anchor,
|
||||
[Description("Raw source code to insert. Do NOT include 'lineNumber:hash|' prefixes.")] string[] newLines)
|
||||
{
|
||||
newLines = SanitizeNewLines(newLines);
|
||||
path = FileTools.ResolvePath(path);
|
||||
@@ -129,11 +129,11 @@ internal static partial class EditTools
|
||||
}
|
||||
}
|
||||
|
||||
[Description("Delete a range of lines from a file, identified by Hashline anchors.")]
|
||||
[Description("Delete a range of lines.")]
|
||||
public static string DeleteRange(
|
||||
[Description("Path to the file.")] string path,
|
||||
[Description("line:hash anchor of the first line to delete (e.g. '4:7c'). Both the line number AND hash must match.")] string startAnchor,
|
||||
[Description("line:hash anchor of the last line to delete (e.g. '6:19'). Both the line number AND hash must match.")] string endAnchor)
|
||||
[Description("First line's line:hash anchor (e.g. '4:7c').")] string startAnchor,
|
||||
[Description("Last line's line:hash anchor (e.g. '6:19').")] string endAnchor)
|
||||
{
|
||||
path = FileTools.ResolvePath(path);
|
||||
Log($"Deleting lines in file: {path}");
|
||||
@@ -162,10 +162,10 @@ internal static partial class EditTools
|
||||
}
|
||||
}
|
||||
|
||||
[Description("Create a new empty file, or a file with initial content. Creates missing parent directories automatically. If the agent doesn't succeed with initial content, they can also create an empty file first and add the content using AppendToFile.")]
|
||||
[Description("Create a new file (parents auto-created). Max initial lines: 200. Alternatively, append lines later.")]
|
||||
public static string CreateFile(
|
||||
[Description("Path to the new file to create.")] string path,
|
||||
[Description("Optional initial content lines. If omitted, creates an empty file. IMPORTANT: Write raw source code only. Do NOT include 'lineNumber:hash|' prefixes — those are display-only metadata from ReadFile, not part of the actual file content.")] string[]? initialLines = null)
|
||||
[Description("Optional initial raw source code. Do NOT include 'lineNumber:hash|' prefixes.")] string[]? initialLines = null)
|
||||
{
|
||||
path = FileTools.ResolvePath(path);
|
||||
Log($"Creating file: {path}");
|
||||
@@ -194,7 +194,7 @@ internal static partial class EditTools
|
||||
}
|
||||
}
|
||||
|
||||
[Description("Delete a file permanently from the disk.")]
|
||||
[Description("Delete a file permanently.")]
|
||||
public static string DeleteFile(
|
||||
[Description("Path to the file to delete.")] string path)
|
||||
{
|
||||
@@ -215,7 +215,7 @@ internal static partial class EditTools
|
||||
}
|
||||
}
|
||||
|
||||
[Description("Rename or move a file. Can move a file to a new directory (which will be created if it doesn't exist).")]
|
||||
[Description("Rename or move a file. Auto-creates target dirs.")]
|
||||
public static string RenameFile(
|
||||
[Description("Current path to the file.")] string sourcePath,
|
||||
[Description("New path for the file.")] string destinationPath)
|
||||
@@ -273,10 +273,10 @@ internal static partial class EditTools
|
||||
}
|
||||
}
|
||||
|
||||
[Description("Append lines to the end of a file without reading it first. Creates the file if it doesn't exist.")]
|
||||
[Description("Append lines to EOF (auto-creating the file if missing).")]
|
||||
public static string AppendToFile(
|
||||
[Description("Path to the file to append to.")] string path,
|
||||
[Description("Lines to append to the end of the file. IMPORTANT: Write raw source code only. Do NOT include 'lineNumber:hash|' prefixes — those are display-only metadata from ReadFile, not part of the actual file content.")] string[] lines)
|
||||
[Description("Raw source code to append. Do NOT include 'lineNumber:hash|' prefixes.")] string[] lines)
|
||||
{
|
||||
lines = SanitizeNewLines(lines);
|
||||
path = FileTools.ResolvePath(path);
|
||||
|
||||
@@ -14,14 +14,14 @@ internal static class FileTools
|
||||
{
|
||||
public static Action<string> Log { get; set; } = Console.WriteLine;
|
||||
|
||||
[Description("Read a file and return its lines tagged with Hashline anchors in the format lineNumber:hash|content. Optionally restrict to a line window.")]
|
||||
[Description("Read a file. Max 200 lines per call. Returns lines with line:hash| anchors.")]
|
||||
public static string ReadFile(
|
||||
[Description("Path to the file to read. Can be relative to the working directory or absolute.")] string path,
|
||||
[Description("First line to return, 1-indexed inclusive. Defaults to 1.")] int startLine = 1,
|
||||
[Description("Last line to return, 1-indexed inclusive. Use 0 for end of file. Defaults to 0.")] int endLine = 0)
|
||||
[Description("Path to the file.")] string path,
|
||||
[Description("First line to return (inclusive). Defaults to 1.")] int startLine = 1,
|
||||
[Description("Last line to return (inclusive). Use 0 for EOF. Defaults to 0.")] int endLine = 0)
|
||||
{
|
||||
path = ResolvePath(path);
|
||||
Log($"Reading file: {path}");
|
||||
Log($"Reading file: {path} {startLine}:{endLine}L");
|
||||
|
||||
if (!File.Exists(path))
|
||||
return $"ERROR: File not found: {path}";
|
||||
@@ -33,6 +33,13 @@ internal static class FileTools
|
||||
if (lines.Length == 0)
|
||||
return $"(empty file: {path})";
|
||||
|
||||
int actualEnd = endLine <= 0 ? lines.Length : Math.Min(endLine, lines.Length);
|
||||
int start = Math.Max(1, startLine);
|
||||
if (actualEnd - start + 1 > 200)
|
||||
{
|
||||
return $"ERROR: File too large to read at once ({lines.Length} lines). Provide startLine and endLine to read a chunk of max 200 lines. Use GrepFile to get an outline (grep 'public') and find the line numbers.";
|
||||
}
|
||||
|
||||
return HashlineEncoder.Encode(lines, startLine, endLine);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -41,10 +48,10 @@ internal static class FileTools
|
||||
}
|
||||
}
|
||||
|
||||
[Description("Search a file for lines matching a regex pattern. Returns only matching lines, already tagged with Hashline anchors so you can reference them in edit operations immediately.")]
|
||||
[Description("Search a file for a regex pattern. Returns matches with line:hash| anchors.")]
|
||||
public static string GrepFile(
|
||||
[Description("Path to the file to search.")] string path,
|
||||
[Description("Regular expression pattern to search for.")] string pattern)
|
||||
[Description("Regex pattern.")] string pattern)
|
||||
{
|
||||
path = ResolvePath(path);
|
||||
Log($"Searching file: {path}");
|
||||
@@ -90,9 +97,9 @@ internal static class FileTools
|
||||
}
|
||||
}
|
||||
|
||||
[Description("List the files and subdirectories in a directory.")]
|
||||
[Description("List files and subdirectories.")]
|
||||
public static string ListDir(
|
||||
[Description("Path to the directory to list. Defaults to the current working directory.")] string path = ".")
|
||||
[Description("Path to the directory.")] string path = ".")
|
||||
{
|
||||
path = ResolvePath(path);
|
||||
Log($"Listing directory: {path}");
|
||||
@@ -122,10 +129,10 @@ internal static class FileTools
|
||||
}
|
||||
}
|
||||
|
||||
[Description("Search for files matching a glob/wildcard pattern (e.g., '*.cs', 'src/**/*.js'). Returns full paths of matching files.")]
|
||||
[Description("Find files matching a glob pattern (e.g. '*.cs', '**/*.json').")]
|
||||
public static string FindFiles(
|
||||
[Description("Path to start the search (directory).")] string path,
|
||||
[Description("Glob pattern to match files (e.g., '*.cs', '**/*.json'). Supports * and ** wildcards.")] string pattern)
|
||||
[Description("Directory to start search.")] string path,
|
||||
[Description("Glob pattern (supports * and **).")] string pattern)
|
||||
{
|
||||
path = ResolvePath(path);
|
||||
Log($"Finding files: {pattern} in {path}");
|
||||
@@ -159,11 +166,11 @@ internal static class FileTools
|
||||
}
|
||||
}
|
||||
|
||||
[Description("Search for a regex pattern across all files in a directory tree. Returns matches with file:line:hash|content format.")]
|
||||
[Description("Recursive regex search across all files. Returns matches with file:line:hash| format.")]
|
||||
public static string GrepRecursive(
|
||||
[Description("Path to the directory to search recursively.")] string path,
|
||||
[Description("Regular expression pattern to search for.")] string pattern,
|
||||
[Description("Optional glob pattern to filter which files to search (e.g., '*.cs'). Defaults to all files.")] string? filePattern = null)
|
||||
[Description("Directory to search.")] string path,
|
||||
[Description("Regex pattern.")] string pattern,
|
||||
[Description("Optional glob to filter files (e.g. '*.cs').")] string? filePattern = null)
|
||||
{
|
||||
path = ResolvePath(path);
|
||||
Log($"Recursive grep: {pattern} in {path}" + (filePattern != null ? $" (files: {filePattern})" : ""));
|
||||
@@ -264,9 +271,9 @@ internal static class FileTools
|
||||
}
|
||||
}
|
||||
|
||||
[Description("Get detailed information about a file (size, permissions, last modified, type, etc.).")]
|
||||
[Description("Get detailed file info (size, last modified, etc).")]
|
||||
public static string GetFileInfo(
|
||||
[Description("Path to the file to get information about.")] string path)
|
||||
[Description("Path to the file.")] string path)
|
||||
{
|
||||
path = ResolvePath(path);
|
||||
Log($"Getting file info: {path}");
|
||||
|
||||
Reference in New Issue
Block a user