1
0

feat: Add file editing tools with replace, delete, move, and write operations, featuring hashline anchor validation and input sanitization.

This commit is contained in:
2026-03-06 02:48:43 +01:00
parent 82ef63c731
commit 8b48b0f866
3 changed files with 109 additions and 0 deletions

View File

@@ -1,5 +1,6 @@
using System.Text.Json.Serialization;
using AnchorCli.OpenRouter;
using AnchorCli.Tools;
namespace AnchorCli;
@@ -19,6 +20,8 @@ namespace AnchorCli;
[JsonSerializable(typeof(Microsoft.Extensions.AI.ChatMessage))]
[JsonSerializable(typeof(System.Collections.Generic.List<Microsoft.Extensions.AI.ChatMessage>))]
[JsonSerializable(typeof(AnchorConfig))]
[JsonSerializable(typeof(BatchOperation))]
[JsonSerializable(typeof(BatchOperation[]))]
[JsonSourceGenerationOptions(
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]

View File

@@ -16,6 +16,7 @@ internal static class ToolRegistry
AIFunctionFactory.Create(FileTools.ListDir, serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.ReplaceLines, serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.DeleteRange, serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.BatchEdit, serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.Delete, serializerOptions: jsonOptions),
AIFunctionFactory.Create(FileTools.FindFiles, serializerOptions: jsonOptions),
AIFunctionFactory.Create(FileTools.GetFileInfo, serializerOptions: jsonOptions),

View File

@@ -4,6 +4,15 @@ using AnchorCli.Hashline;
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>
/// Mutating file tools exposed to the LLM as AIFunctions.
/// 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)]
private static partial Regex MyRegex();
}