summaryrefslogtreecommitdiff
path: root/agent/tools/edit.lua
diff options
context:
space:
mode:
authorT <t@tjp.lol>2026-05-27 17:07:49 -0600
committerT <t@tjp.lol>2026-05-27 18:05:12 -0600
commit48651e123ef0cf1d02eac781902517c0628b310a (patch)
treef8f87205b2ca716f406ae69288ebef622659dd9e /agent/tools/edit.lua
parentf72b5793a5c7e7891e4904be73c0bc6bc038fa18 (diff)
real coding agent tools
Diffstat (limited to 'agent/tools/edit.lua')
-rw-r--r--agent/tools/edit.lua193
1 files changed, 193 insertions, 0 deletions
diff --git a/agent/tools/edit.lua b/agent/tools/edit.lua
new file mode 100644
index 0000000..a56c334
--- /dev/null
+++ b/agent/tools/edit.lua
@@ -0,0 +1,193 @@
+-- Apply a batch of exact-text replacements to a file. Pi-style terse
+-- tool description; the rules are enforced here rather than re-stated
+-- in prose, with the rejection message naming exactly which rule each
+-- entry tripped so the model can self-correct on the next call.
+--
+-- Rejection rules (atomic — any failure rejects the whole call):
+-- 1. Each `old` must occur exactly once in the original file.
+-- 2. `old` may not be the empty string.
+-- 3. Resolved ranges of `old` matches must not overlap.
+-- All matches are evaluated against the original file, not
+-- progressively against the result of earlier entries.
+
+return {
+ name = "edit",
+ description = "Apply exact-text replacements to a file. Each entry's `old` must match exactly once in the original file (not progressively). Edits whose ranges overlap, match zero times, or match multiple times reject the entire call — no partial edits, file untouched. Keep `old` minimal but unique; widen with surrounding context if needed.",
+ schema = {
+ type = "object",
+ properties = {
+ path = { type = "string", description = "Path to the file (relative or absolute)." },
+ edits = {
+ type = "array",
+ description = "Replacements applied atomically.",
+ minItems = 1,
+ items = {
+ type = "object",
+ properties = {
+ old = { type = "string", description = "Exact substring to find; must appear exactly once." },
+ new = { type = "string", description = "Replacement text (may be empty to delete)." },
+ },
+ required = { "old", "new" },
+ },
+ },
+ },
+ required = { "path", "edits" },
+ },
+ handler = function(input)
+ local path = input.path
+ local edits = input.edits
+
+ if type(path) ~= "string" or path == "" then
+ return "Error: `path` must be a non-empty string."
+ end
+ if type(edits) ~= "table" or #edits == 0 then
+ return "Error: `edits` must be a non-empty array."
+ end
+
+ -- Load the file.
+ local f, open_err = io.open(path, "rb")
+ if not f then
+ return "Error: " .. (open_err or ("could not open " .. path))
+ end
+ local original = f:read("a")
+ f:close()
+ if not original then
+ return "Error: failed to read " .. path
+ end
+
+ -- Validate every entry first, collecting the byte-range each
+ -- `before` resolves to. We need this in two passes:
+ -- 1. uniqueness check (count occurrences)
+ -- 2. overlap check (compare resolved ranges pairwise)
+ local problems = {} -- list of "edit #N: ..." strings
+ local ranges = {} -- per-edit { start_byte, end_byte } or nil
+
+ for i, e in ipairs(edits) do
+ if type(e) ~= "table" then
+ problems[#problems + 1] = string.format(
+ "edit #%d: entry is not an object", i
+ )
+ ranges[i] = nil
+ elseif type(e.old) ~= "string" or type(e.new) ~= "string" then
+ problems[#problems + 1] = string.format(
+ "edit #%d: `old` and `new` must both be strings", i
+ )
+ ranges[i] = nil
+ elseif e.old == "" then
+ problems[#problems + 1] = string.format(
+ "edit #%d: `old` is the empty string (would be ambiguous)", i
+ )
+ ranges[i] = nil
+ else
+ -- string.find with `plain = true` skips pattern parsing,
+ -- so the search is literal. Count occurrences by walking.
+ local count = 0
+ local first_start, first_end = nil, nil
+ local search_from = 1
+ while true do
+ local s, ee = string.find(original, e.old, search_from, true)
+ if not s then break end
+ count = count + 1
+ if count == 1 then
+ first_start, first_end = s, ee
+ end
+ search_from = ee + 1
+ end
+ if count == 0 then
+ -- Truncate the previewed snippet so the error stays
+ -- legible in tool output.
+ local snippet = e.old
+ if #snippet > 80 then
+ snippet = snippet:sub(1, 77) .. "..."
+ end
+ problems[#problems + 1] = string.format(
+ "edit #%d: `old` not found in file. Snippet: %q",
+ i, snippet
+ )
+ ranges[i] = nil
+ elseif count > 1 then
+ problems[#problems + 1] = string.format(
+ "edit #%d: `old` matches %d times (need exactly 1). " ..
+ "Widen with surrounding context to disambiguate.",
+ i, count
+ )
+ ranges[i] = nil
+ else
+ ranges[i] = { first_start, first_end }
+ end
+ end
+ end
+
+ -- Overlap check (only meaningful if we got this far without
+ -- per-edit problems; otherwise the user already has plenty to
+ -- fix and overlap noise would be confusing).
+ if #problems == 0 then
+ for i = 1, #ranges do
+ for j = i + 1, #ranges do
+ local a, b = ranges[i], ranges[j]
+ if a and b and not (a[2] < b[1] or b[2] < a[1]) then
+ problems[#problems + 1] = string.format(
+ "edits #%d and #%d target overlapping regions " ..
+ "(bytes %d-%d and %d-%d). Combine them into one " ..
+ "edit with a wider `old`.",
+ i, j, a[1], a[2], b[1], b[2]
+ )
+ end
+ end
+ end
+ end
+
+ if #problems > 0 then
+ return "Error: edit rejected (" .. #problems .. " issue" ..
+ (#problems == 1 and "" or "s") .. "); file not modified.\n" ..
+ " - " .. table.concat(problems, "\n - ") .. "\n"
+ end
+
+ -- Apply edits. We sort by start position ascending and rebuild
+ -- the file from non-overlapping slices. This avoids the O(N*M)
+ -- repeated-substring-substitution and keeps each edit's
+ -- `before` matched against the original (not the in-progress
+ -- result).
+ local order = {}
+ for i = 1, #edits do order[i] = i end
+ table.sort(order, function(a, b)
+ return ranges[a][1] < ranges[b][1]
+ end)
+
+ local out = {}
+ local cursor = 1
+ for _, idx in ipairs(order) do
+ local r = ranges[idx]
+ if r[1] > cursor then
+ out[#out + 1] = original:sub(cursor, r[1] - 1)
+ end
+ out[#out + 1] = edits[idx].new
+ cursor = r[2] + 1
+ end
+ if cursor <= #original then
+ out[#out + 1] = original:sub(cursor)
+ end
+ local new_content = table.concat(out)
+
+ -- Write back.
+ local wf, werr = io.open(path, "wb")
+ if not wf then
+ return "Error: file read OK but failed to reopen for write: " ..
+ (werr or "?")
+ end
+ local ok, write_err = wf:write(new_content)
+ if not ok then
+ wf:close()
+ return "Error: write failed: " .. tostring(write_err)
+ end
+ local close_ok, close_err = wf:close()
+ if not close_ok then
+ return "Error: close failed: " .. tostring(close_err)
+ end
+
+ return string.format(
+ "Applied %d edit%s to %s. File is now %d bytes (was %d).",
+ #edits, (#edits == 1 and "" or "s"), path, #new_content, #original
+ )
+ end,
+}