feat: Add file editing tools with replace, delete, move, and write operations, featuring hashline anchor validation and input sanitization.
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using AnchorCli.OpenRouter;
|
using AnchorCli.OpenRouter;
|
||||||
|
using AnchorCli.Tools;
|
||||||
|
|
||||||
namespace AnchorCli;
|
namespace AnchorCli;
|
||||||
|
|
||||||
@@ -19,6 +20,8 @@ namespace AnchorCli;
|
|||||||
[JsonSerializable(typeof(Microsoft.Extensions.AI.ChatMessage))]
|
[JsonSerializable(typeof(Microsoft.Extensions.AI.ChatMessage))]
|
||||||
[JsonSerializable(typeof(System.Collections.Generic.List<Microsoft.Extensions.AI.ChatMessage>))]
|
[JsonSerializable(typeof(System.Collections.Generic.List<Microsoft.Extensions.AI.ChatMessage>))]
|
||||||
[JsonSerializable(typeof(AnchorConfig))]
|
[JsonSerializable(typeof(AnchorConfig))]
|
||||||
|
[JsonSerializable(typeof(BatchOperation))]
|
||||||
|
[JsonSerializable(typeof(BatchOperation[]))]
|
||||||
[JsonSourceGenerationOptions(
|
[JsonSourceGenerationOptions(
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
|
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ internal static class ToolRegistry
|
|||||||
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.DeleteRange, serializerOptions: jsonOptions),
|
AIFunctionFactory.Create(EditTools.DeleteRange, serializerOptions: jsonOptions),
|
||||||
|
AIFunctionFactory.Create(EditTools.BatchEdit, serializerOptions: jsonOptions),
|
||||||
AIFunctionFactory.Create(EditTools.Delete, serializerOptions: jsonOptions),
|
AIFunctionFactory.Create(EditTools.Delete, serializerOptions: jsonOptions),
|
||||||
AIFunctionFactory.Create(FileTools.FindFiles, serializerOptions: jsonOptions),
|
AIFunctionFactory.Create(FileTools.FindFiles, serializerOptions: jsonOptions),
|
||||||
AIFunctionFactory.Create(FileTools.GetFileInfo, serializerOptions: jsonOptions),
|
AIFunctionFactory.Create(FileTools.GetFileInfo, serializerOptions: jsonOptions),
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ using AnchorCli.Hashline;
|
|||||||
|
|
||||||
namespace AnchorCli.Tools;
|
namespace AnchorCli.Tools;
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a single operation within a batch edit.
|
||||||
|
public record BatchOperation(
|
||||||
|
[property: Description("Operation type: 'replace' or 'delete'")] string Type,
|
||||||
|
[property: Description("First line's line:hash anchor (e.g. '5:a3')")] string? StartAnchor,
|
||||||
|
[property: Description("Last line's line:hash anchor (e.g. '8:d4')")] string? EndAnchor,
|
||||||
|
[property: Description("Raw source code to insert. Required for 'replace' operations.")] string[]? NewLines);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Mutating file tools exposed to the LLM as AIFunctions.
|
/// Mutating file tools exposed to the LLM as AIFunctions.
|
||||||
/// Every operation validates Hashline anchors (line:hash format) before touching the file.
|
/// Every operation validates Hashline anchors (line:hash format) before touching the file.
|
||||||
@@ -283,6 +292,102 @@ internal static partial class EditTools
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Description("Apply multiple edits atomically. All anchors are validated before any changes are made.")]
|
||||||
|
public static string BatchEdit(
|
||||||
|
[Description("Path to the file.")] string path,
|
||||||
|
[Description("Array of operations to apply. Operations are applied in bottom-to-top order automatically.")] BatchOperation[] operations)
|
||||||
|
{
|
||||||
|
path = FileTools.ResolvePath(path);
|
||||||
|
Log($"BATCH_EDIT: {path}");
|
||||||
|
Log($" Operations: {operations.Length}");
|
||||||
|
|
||||||
|
if (!File.Exists(path))
|
||||||
|
return $"ERROR: File not found: {path}";
|
||||||
|
|
||||||
|
if (operations.Length == 0)
|
||||||
|
return "ERROR: No operations provided";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Read file once
|
||||||
|
string[] lines = File.ReadAllLines(path);
|
||||||
|
|
||||||
|
// Pre-validate all anchors against original content (fail-fast)
|
||||||
|
var resolvedOps = new List<(int StartIdx, int EndIdx, BatchOperation Op)>();
|
||||||
|
for (int i = 0; i < operations.Length; i++)
|
||||||
|
{
|
||||||
|
var op = operations[i];
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(op.Type))
|
||||||
|
return $"ERROR: Operation {i}: Type is required (use 'replace' or 'delete')";
|
||||||
|
|
||||||
|
var opType = op.Type.ToLowerInvariant();
|
||||||
|
if (opType != "replace" && opType != "delete")
|
||||||
|
return $"ERROR: Operation {i}: Invalid type '{op.Type}'. Must be 'replace' or 'delete'";
|
||||||
|
|
||||||
|
if (opType == "replace" && op.NewLines == null)
|
||||||
|
return $"ERROR: Operation {i}: 'replace' requires NewLines";
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(op.StartAnchor) || string.IsNullOrEmpty(op.EndAnchor))
|
||||||
|
return $"ERROR: Operation {i}: StartAnchor and EndAnchor are required";
|
||||||
|
|
||||||
|
if (!HashlineValidator.TryResolveRange(op.StartAnchor, op.EndAnchor, lines,
|
||||||
|
out int startIdx, out int endIdx, out string error))
|
||||||
|
return $"ERROR: Operation {i}: Anchor validation failed\n{error}";
|
||||||
|
|
||||||
|
resolvedOps.Add((startIdx, endIdx, op));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for overlapping ranges (conflicting operations)
|
||||||
|
for (int i = 0; i < resolvedOps.Count; i++)
|
||||||
|
{
|
||||||
|
for (int j = i + 1; j < resolvedOps.Count; j++)
|
||||||
|
{
|
||||||
|
var (startA, endA, _) = resolvedOps[i];
|
||||||
|
var (startB, endB, _) = resolvedOps[j];
|
||||||
|
|
||||||
|
if (!(endA < startB || endB < startA))
|
||||||
|
return $"ERROR: Operations {i} and {j} have overlapping ranges. " +
|
||||||
|
$"Range [{startA}-{endA}] overlaps with [{startB}-{endB}].";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort operations bottom-to-top (by start index descending) for safe application
|
||||||
|
var sortedOps = resolvedOps.OrderByDescending(x => x.StartIdx).ToList();
|
||||||
|
|
||||||
|
// Apply all operations to a single buffer
|
||||||
|
var result = new List<string>(lines.Length);
|
||||||
|
int nextLineIdx = 0;
|
||||||
|
|
||||||
|
foreach (var (startIdx, endIdx, op) in sortedOps)
|
||||||
|
{
|
||||||
|
// Copy lines before this operation
|
||||||
|
if (startIdx > nextLineIdx)
|
||||||
|
result.AddRange(lines[nextLineIdx..startIdx]);
|
||||||
|
|
||||||
|
// Apply operation
|
||||||
|
var opType = op.Type.ToLowerInvariant();
|
||||||
|
if (opType == "replace")
|
||||||
|
result.AddRange(SanitizeNewLines(op.NewLines!));
|
||||||
|
// delete: don't add anything (skip the range)
|
||||||
|
|
||||||
|
nextLineIdx = endIdx + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy remaining lines after the last operation
|
||||||
|
if (nextLineIdx < lines.Length)
|
||||||
|
result.AddRange(lines[nextLineIdx..]);
|
||||||
|
|
||||||
|
// Write file once
|
||||||
|
File.WriteAllLines(path, result);
|
||||||
|
return $"OK fp:{HashlineEncoder.FileFingerprint([.. result])}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return $"ERROR batch editing '{path}': {ex.Message}\nThis is a bug. Tell the user about it.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[GeneratedRegex(@"^\d+:[0-9a-fA-F]{2}\|", RegexOptions.Compiled)]
|
[GeneratedRegex(@"^\d+:[0-9a-fA-F]{2}\|", RegexOptions.Compiled)]
|
||||||
private static partial Regex MyRegex();
|
private static partial Regex MyRegex();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user