1
0

feat: consolidate file write, move, grep, and delete operations into unified tools and update context compaction heuristics

This commit is contained in:
2026-03-06 01:14:56 +01:00
parent 7a6e9785d6
commit 003345edc0
6 changed files with 230 additions and 261 deletions

View File

@@ -71,10 +71,10 @@ internal sealed partial class ContextCompactor(IChatClient client)
string reason = ""; string reason = "";
// Rule 1: Deduplication. If we have already seen this file in a newer message (since we are walking backward), redact this one. // 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) if (filesRead.TryGetValue(filePath, out int count) && count >= 5)
{ {
shouldRedact = true; shouldRedact = true;
reason = "deduplication — you read this file 3 or more times later"; 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. // Rule 2: TTL. If this was read 2 or more user turns ago, redact it.
else if (userTurnsSeen >= 2) else if (userTurnsSeen >= 2)

View File

@@ -12,22 +12,18 @@ internal static class ToolRegistry
return new List<AITool> return new List<AITool>
{ {
AIFunctionFactory.Create(FileTools.ReadFile, serializerOptions: jsonOptions), AIFunctionFactory.Create(FileTools.ReadFile, serializerOptions: jsonOptions),
AIFunctionFactory.Create(FileTools.GrepFile, serializerOptions: jsonOptions), AIFunctionFactory.Create(FileTools.Grep, serializerOptions: jsonOptions),
AIFunctionFactory.Create(FileTools.ListDir, serializerOptions: jsonOptions), AIFunctionFactory.Create(FileTools.ListDir, serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.ReplaceLines, serializerOptions: jsonOptions), AIFunctionFactory.Create(EditTools.ReplaceLines, serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.InsertAfter, serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.DeleteRange, serializerOptions: jsonOptions), AIFunctionFactory.Create(EditTools.DeleteRange, serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.CreateFile, serializerOptions: jsonOptions), AIFunctionFactory.Create(EditTools.Delete, serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.DeleteFile, serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.MoveFile, serializerOptions: jsonOptions),
AIFunctionFactory.Create(DirTools.CreateDir, serializerOptions: jsonOptions),
AIFunctionFactory.Create(DirTools.RenameDir, serializerOptions: jsonOptions),
AIFunctionFactory.Create(DirTools.DeleteDir, serializerOptions: jsonOptions),
AIFunctionFactory.Create(FileTools.FindFiles, serializerOptions: jsonOptions), AIFunctionFactory.Create(FileTools.FindFiles, serializerOptions: jsonOptions),
AIFunctionFactory.Create(FileTools.GrepRecursive, serializerOptions: jsonOptions),
AIFunctionFactory.Create(FileTools.GetFileInfo, serializerOptions: jsonOptions), AIFunctionFactory.Create(FileTools.GetFileInfo, serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.AppendToFile, serializerOptions: jsonOptions), AIFunctionFactory.Create(EditTools.WriteToFile, serializerOptions: jsonOptions),
AIFunctionFactory.Create(CommandTool.ExecuteCommand, serializerOptions: jsonOptions), AIFunctionFactory.Create(CommandTool.ExecuteCommand, serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.MoveFile, serializerOptions: jsonOptions),
AIFunctionFactory.Create(DirTools.RenameDir, serializerOptions: jsonOptions),
AIFunctionFactory.Create(DirTools.CreateDir, serializerOptions: jsonOptions),
}; };
} }
} }

View File

@@ -39,27 +39,6 @@ internal static class DirTools
} }
} }
[Description("Delete a directory and all its contents permanently.")]
public static string DeleteDir(
[Description("Path to the directory to delete.")] string path,
[Description("If true, delete recursively. Defaults to true.")] bool recursive = true)
{
path = ResolvePath(path);
Log($"Deleting directory: {path}");
if (!Directory.Exists(path))
return $"ERROR: Directory not found: {path}";
try
{
Directory.Delete(path, recursive);
return $"OK: Directory deleted: '{path}'";
}
catch (Exception ex)
{
return $"ERROR deleting directory '{path}': {ex.Message}";
}
}
[Description("Create a new directory. Creates parent directories if they don't exist. Returns OK on success, or an error message if the directory already exists or creation fails.")] [Description("Create a new directory. Creates parent directories if they don't exist. Returns OK on success, or an error message if the directory already exists or creation fails.")]
public static string CreateDir( public static string CreateDir(
[Description("Path to the directory to create.")] string path) [Description("Path to the directory to create.")] string path)

View File

@@ -90,44 +90,6 @@ internal static partial class EditTools
} }
} }
[Description("Insert lines after the specified line:hash anchor.")]
public static string InsertAfter(
[Description("Path to the file.")] string path,
[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);
Log($"INSERT_AFTER: {path}");
Log($" Anchor: {anchor}");
Log($" Inserting {newLines.Length} lines after line {anchor.Split(':')[0]}");
if (!File.Exists(path))
return $"ERROR: File not found: {path}";
try
{
string[] lines = File.ReadAllLines(path);
if (!HashlineValidator.TryResolve(anchor, lines, out int idx, out string error))
return $"ERROR: {error}";
var result = new List<string>(lines.Length + newLines.Length);
result.AddRange(lines[..(idx + 1)]);
result.AddRange(newLines);
result.AddRange(lines[(idx + 1)..]);
File.WriteAllLines(path, result);
return $"OK fp:{HashlineEncoder.FileFingerprint([.. result])}";
}
catch (Exception ex)
{
return $"ERROR modifying '{path}': {ex.Message}";
}
}
[Description("Delete a range of lines.")] [Description("Delete a range of lines.")]
public static string DeleteRange( public static string DeleteRange(
@@ -162,45 +124,34 @@ internal static partial class EditTools
} }
} }
[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("Delete a file or directory. Use mode='file' to delete a file, mode='dir' to delete a directory.")]
[Description("Optional initial raw source code. Do NOT include 'lineNumber:hash|' prefixes.")] string[]? initialLines = null) public static string Delete(
[Description("Path to the file or directory to delete.")] string path,
[Description("Type of deletion: 'file' or 'dir'. Defaults to 'file'.")] string mode = "file")
{ {
path = FileTools.ResolvePath(path); path = FileTools.ResolvePath(path);
Log($"Creating file: {path}"); string targetType = mode.ToLower() == "dir" ? "directory" : "file";
Log($"Deleting {targetType}: {path}");
if (File.Exists(path)) if (mode.ToLower() == "dir")
return $"ERROR: File already exists: {path}"; {
if (!Directory.Exists(path))
return $"ERROR: Directory not found: {path}";
try try
{ {
if (initialLines is not null) Directory.Delete(path, true);
initialLines = SanitizeNewLines(initialLines); return $"OK: Directory deleted: '{path}'";
string? dir = Path.GetDirectoryName(path);
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
if (initialLines is not null && initialLines.Length > 0)
File.WriteAllLines(path, initialLines);
else
File.WriteAllText(path, "");
return $"OK fp:{HashlineEncoder.FileFingerprint(initialLines ?? [])}";
} }
catch (Exception ex) catch (Exception ex)
{ {
return $"ERROR creating '{path}': {ex.Message}"; return $"ERROR deleting directory '{path}': {ex.Message}";
} }
} }
else
[Description("Delete a file permanently.")]
public static string DeleteFile(
[Description("Path to the file to delete.")] string path)
{ {
path = FileTools.ResolvePath(path);
Log($"Deleting file: {path}");
if (!File.Exists(path)) if (!File.Exists(path))
return $"ERROR: File not found: {path}"; return $"ERROR: File not found: {path}";
@@ -214,6 +165,7 @@ internal static partial class EditTools
return $"ERROR deleting '{path}': {ex.Message}"; return $"ERROR deleting '{path}': {ex.Message}";
} }
} }
}
[Description("Move or copy a file to a new location.")] [Description("Move or copy a file to a new location.")]
public static string MoveFile( public static string MoveFile(
@@ -250,15 +202,19 @@ internal static partial class EditTools
} }
} }
[Description("Append lines to EOF (auto-creating the file if missing).")]
public static string AppendToFile( [Description("Write to a file with different modes: create, append, or insert.")]
[Description("Path to the file to append to.")] string path, public static string WriteToFile(
[Description("Raw source code to append. Do NOT include 'lineNumber:hash|' prefixes.")] string[] lines) [Description("Path to the file.")] string path,
[Description("Content to write.")] string[] content,
[Description("Write mode: 'create' (error if exists), 'append' (creates if missing), 'insert' (requires anchor)")] string mode = "create",
[Description("line:hash anchor to insert after (required for mode='insert', e.g. '3:0e').")] string? anchor = null)
{ {
lines = SanitizeNewLines(lines); content = SanitizeNewLines(content);
path = FileTools.ResolvePath(path); path = FileTools.ResolvePath(path);
Log($"Appending to file: {path}"); Log($"WRITE_TO_FILE: {path}");
Log($" Appending {lines.Length} lines"); Log($" Mode: {mode}");
Log($" Writing {content.Length} lines");
try try
{ {
@@ -266,6 +222,20 @@ internal static partial class EditTools
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir)) if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
switch (mode.ToLower())
{
case "create":
if (File.Exists(path))
return $"ERROR: File already exists: {path}";
if (content.Length > 0)
File.WriteAllLines(path, content);
else
File.WriteAllText(path, "");
return $"OK fp:{HashlineEncoder.FileFingerprint(content)}";
case "append":
if (!File.Exists(path)) if (!File.Exists(path))
{ {
File.WriteAllText(path, ""); File.WriteAllText(path, "");
@@ -274,18 +244,42 @@ internal static partial class EditTools
using (var writer = new System.IO.StreamWriter(path, true)) using (var writer = new System.IO.StreamWriter(path, true))
{ {
foreach (var line in lines) foreach (var line in content)
{ {
writer.WriteLine(line); writer.WriteLine(line);
} }
} }
string[] allLines = File.ReadAllLines(path); string[] appendedLines = File.ReadAllLines(path);
return $"OK fp:{HashlineEncoder.FileFingerprint([.. allLines])}"; return $"OK fp:{HashlineEncoder.FileFingerprint([.. appendedLines])}";
case "insert":
if (!File.Exists(path))
return $"ERROR: File not found: {path}";
if (string.IsNullOrEmpty(anchor))
return "ERROR: mode='insert' requires an anchor parameter";
string[] lines = File.ReadAllLines(path);
if (!HashlineValidator.TryResolve(anchor, lines, out int idx, out string error))
return $"ERROR: {error}";
var result = new List<string>(lines.Length + content.Length);
result.AddRange(lines[..(idx + 1)]);
result.AddRange(content);
result.AddRange(lines[(idx + 1)..]);
File.WriteAllLines(path, result);
return $"OK fp:{HashlineEncoder.FileFingerprint([.. result])}";
default:
return $"ERROR: Unknown mode '{mode}'. Valid modes: create, append, insert";
}
} }
catch (Exception ex) catch (Exception ex)
{ {
return $"ERROR appending to '{path}': {ex.Message}"; return $"ERROR writing to '{path}': {ex.Message}";
} }
} }

View File

@@ -56,54 +56,6 @@ internal static class FileTools
} }
} }
[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("Regex pattern.")] string pattern)
{
path = ResolvePath(path);
Log($"Searching file: {path}");
if (!File.Exists(path))
return $"ERROR: File not found: {path}";
Regex regex;
try
{
regex = new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase);
}
catch (Exception ex)
{
return $"ERROR: Invalid regex pattern '{pattern}': {ex.Message}";
}
try
{
string[] lines = File.ReadAllLines(path);
var sb = new System.Text.StringBuilder();
int matchCount = 0;
for (int i = 0; i < lines.Length; i++)
{
if (regex.IsMatch(lines[i]))
{
int lineNumber = i + 1;
string hash = HashlineEncoder.ComputeHash(lines[i].AsSpan(), lineNumber);
sb.Append(lineNumber).Append(':').Append(hash).Append('|').AppendLine(lines[i]);
matchCount++;
}
}
if (matchCount == 0)
return $"(no matches for '{pattern}' in {path})";
return sb.ToString();
}
catch (Exception ex)
{
return $"ERROR searching '{path}': {ex.Message}";
}
}
[Description("List files and subdirectories.")] [Description("List files and subdirectories.")]
public static string ListDir( public static string ListDir(
@@ -174,13 +126,63 @@ internal static class FileTools
} }
} }
[Description("Recursive regex search across all files. Returns matches with file:line:hash| format.")]
public static string GrepRecursive( [Description("Consolidated grep operation for single file or recursive directory search.")]
[Description("Directory to search.")] string path, public static string Grep(
[Description("Directory to search (for recursive mode) or file path (for file mode).")] string path,
[Description("Regex pattern.")] string pattern, [Description("Regex pattern.")] string pattern,
[Description("Optional glob to filter files (e.g. '*.cs').")] string? filePattern = null) [Description("Mode: 'file' for single file, 'recursive' for directory search.")] string mode = "recursive",
[Description("Optional glob to filter files in recursive mode (e.g. '*.cs').")] string? filePattern = null)
{ {
path = ResolvePath(path); path = ResolvePath(path);
mode = mode.ToLowerInvariant();
if (mode == "file")
{
Log($"Searching file: {path}");
if (!File.Exists(path))
return $"ERROR: File not found: {path}";
Regex regex;
try
{
regex = new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase);
}
catch (Exception ex)
{
return $"ERROR: Invalid regex pattern '{pattern}': {ex.Message}";
}
try
{
string[] lines = File.ReadAllLines(path);
var sb = new System.Text.StringBuilder();
int matchCount = 0;
for (int i = 0; i < lines.Length; i++)
{
if (regex.IsMatch(lines[i]))
{
int lineNumber = i + 1;
string hash = HashlineEncoder.ComputeHash(lines[i].AsSpan(), lineNumber);
sb.Append(lineNumber).Append(':').Append(hash).Append('|').AppendLine(lines[i]);
matchCount++;
}
}
if (matchCount == 0)
return $"(no matches for '{pattern}' in {path})";
return sb.ToString();
}
catch (Exception ex)
{
return $"ERROR searching '{path}': {ex.Message}";
}
}
else if (mode == "recursive")
{
Log($"Recursive grep: {pattern} in {path}" + (filePattern != null ? $" (files: {filePattern})" : "")); Log($"Recursive grep: {pattern} in {path}" + (filePattern != null ? $" (files: {filePattern})" : ""));
if (!Directory.Exists(path)) if (!Directory.Exists(path))
@@ -245,6 +247,11 @@ internal static class FileTools
return $"ERROR in recursive grep: {ex.Message}"; return $"ERROR in recursive grep: {ex.Message}";
} }
} }
else
{
return $"ERROR: Invalid mode '{mode}'. Use 'file' or 'recursive'.";
}
}
/// <summary> /// <summary>
/// Safely enumerates files recursively, skipping inaccessible and non-useful directories. /// Safely enumerates files recursively, skipping inaccessible and non-useful directories.

View File

@@ -49,7 +49,7 @@ public static string MoveFile(
- Both create parent directories - Both create parent directories
- Similar error handling patterns - Similar error handling patterns
## 3. Grep Operations ## 4. Grep Operations ✅ DONE
**Current tools:** `GrepFile`, `GrepRecursive` **Current tools:** `GrepFile`, `GrepRecursive`
@@ -59,13 +59,13 @@ public static string MoveFile(
public static string Grep( public static string Grep(
string path, string path,
string pattern, string pattern,
bool recursive = false, string mode = "recursive",
string? filePattern = null) string? filePattern = null)
``` ```
**Behavior:** **Behavior:**
- `recursive=false` - Searches single file (current GrepFile) - `mode="file"` - Searches single file (current GrepFile)
- `recursive=true` - Searches directory recursively (current GrepRecursive) - `mode="recursive"` - Searches directory recursively (current GrepRecursive)
- `filePattern` - Optional glob to filter files when recursive - `filePattern` - Optional glob to filter files when recursive
**Benefits:** **Benefits:**
@@ -73,7 +73,7 @@ public static string Grep(
- Reduces 2 tools to 1 - Reduces 2 tools to 1
- Cleaner API for LLM - Cleaner API for LLM
## 4. Delete Operations ## 5. Delete Operations ✅ DONE
**Current tools:** `DeleteFile`, `DeleteDir` **Current tools:** `DeleteFile`, `DeleteDir`
@@ -82,31 +82,24 @@ public static string Grep(
```csharp ```csharp
public static string Delete( public static string Delete(
string path, string path,
bool recursive = true) string mode = "file")
``` ```
**Behavior:** **Behavior:**
- Auto-detects if path is file or directory - `mode="file"` - Deletes a file
- `recursive=true` - Delete directory and all contents - `mode="dir"` - Deletes a directory (recursive)
- `recursive=false` - Only matters for directories (error if not empty)
**Benefits:** **Benefits:**
- Auto-detects file vs directory - Unified interface for all deletion
- Similar error handling patterns - Similar error handling patterns
- Reduces 2 tools to 1 - Reduces 2 tools to 1
## Summary These consolidations reduced the tool count from 17 to 13 tools (4 completed), making the API simpler and easier for the LLM to use effectively.
These consolidations would reduce the tool count from 17 to 13 tools, making the API simpler and easier for the LLM to use effectively. **Completed merges**:
1. ✅ File Move Operations (2 → 1) - **DONE**
2. ✅ File Write Operations (3 → 1) - **DONE**
3. ✅ Delete Operations (2 → 1) - **DONE**
4. ✅ Grep Operations (2 → 1) - **DONE**
**High priority merges:** **All high priority merges completed!**
1. ✅ File Write Operations (3 → 1)
2. ✅ File Move Operations (2 → 1)
3. ✅ Grep Operations (2 → 1)
4. ✅ Delete Operations (2 → 1)
**Kept separate:**
- `ReadFile` - distinct read-only operation
- `ListDir`, `FindFiles`, `GetFileInfo` - different purposes
- `CreateDir` - simple enough to keep standalone
- `ReplaceLines`, `InsertAfter`, `DeleteRange` - too complex to merge without confusing LLM