summaryrefslogtreecommitdiff
path: root/agent/tools/edit.lua
diff options
context:
space:
mode:
Diffstat (limited to 'agent/tools/edit.lua')
-rw-r--r--agent/tools/edit.lua297
1 files changed, 153 insertions, 144 deletions
diff --git a/agent/tools/edit.lua b/agent/tools/edit.lua
index ec96aa6..b32516a 100644
--- a/agent/tools/edit.lua
+++ b/agent/tools/edit.lua
@@ -10,8 +10,39 @@
-- All matches are evaluated against the original file, not
-- progressively against the result of earlier entries.
+local panto = require("panto")
local uv = require("luv")
+local tool = {
+ name = "std.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" },
+ },
+}
+
+panto.ext.on("tool", function(e)
+ if e.tool_name ~= tool.name then return end
+ e:set_component(e:get_component())
+end)
+
-- ---------------------------------------------------------------------------
-- Coroutine-synchronous libuv wrappers. panto runs each tool handler in
-- its own coroutine and drives a single `uv.run()` to completion; a
@@ -86,170 +117,148 @@ local function write_all(path, content)
return true, nil
end
-return {
- name = "std.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
+tool.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
+ 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 original, read_err = read_all(path)
- if not original then
- return "Error: " .. (read_err or ("could not read " .. path))
- end
+ -- Load the file.
+ local original, read_err = read_all(path)
+ if not original then
+ return "Error: " .. (read_err or ("could not 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
+ -- 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
+ 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` and `new` must both be strings", i
+ "edit #%d: `old` not found in file. Snippet: %q",
+ i, snippet
)
ranges[i] = nil
- elseif e.old == "" then
+ elseif count > 1 then
problems[#problems + 1] = string.format(
- "edit #%d: `old` is the empty string (would be ambiguous)", i
+ "edit #%d: `old` matches %d times (need exactly 1). " ..
+ "Widen with surrounding context to disambiguate.",
+ i, count
)
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
+ 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
+ -- 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
+ 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)
+ -- 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)
+ 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
- local new_content = table.concat(out)
+ 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 ok, write_err = write_all(path, new_content)
- if not ok then
- return "Error: file read OK but write failed: " ..
- tostring(write_err)
- end
+ -- Write back.
+ local ok, write_err = write_all(path, new_content)
+ if not ok then
+ return "Error: file read OK but write failed: " ..
+ tostring(write_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,
-}
+ 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
+
+return tool