-- Apply a batch of exact-text replacements to a file. Keep the tool -- description terse; 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. 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 -- handler may only block by yielding on a pending libuv op whose -- callback resumes it exactly once. `await` encapsulates that: arm one -- op, yield, return the callback's `(err, value)` results. -- --------------------------------------------------------------------------- local function await(arm) local co = assert(coroutine.running(), "edit: await must run inside a tool handler coroutine") local res, fired = nil, false arm(function(...) assert(not fired, "edit: libuv callback fired twice") fired = true res = table.pack(...) local ok, err = coroutine.resume(co) if not ok then error(err, 0) end end) coroutine.yield() return table.unpack(res, 1, res.n) end local function fs_open(path, flags, mode) return await(function(resolve) uv.fs_open(path, flags, mode, resolve) end) end local function fs_fstat(fd) return await(function(resolve) uv.fs_fstat(fd, resolve) end) end local function fs_read(fd, size, offset) return await(function(resolve) uv.fs_read(fd, size, offset, resolve) end) end local function fs_write(fd, data, offset) return await(function(resolve) uv.fs_write(fd, data, offset, resolve) end) end local function fs_close(fd) return await(function(resolve) uv.fs_close(fd, resolve) end) end -- Read an entire file into a string. Returns (content, nil) or -- (nil, err). EOF on `fs_read` is the empty string, not nil. local function read_all(path) local err, fd = fs_open(path, "r", 0) if not fd then return nil, err end local serr, st = fs_fstat(fd) if not st then fs_close(fd); return nil, serr end local parts, offset = {}, 0 while true do local rerr, data = fs_read(fd, 64 * 1024, offset) if rerr then fs_close(fd); return nil, rerr end if data == nil or #data == 0 then break end parts[#parts + 1] = data offset = offset + #data end fs_close(fd) return table.concat(parts), nil end -- Overwrite a file with `content` (O_WRONLY|O_CREAT|O_TRUNC, 0644). -- Returns (true, nil) or (nil, err). local function write_all(path, content) local err, fd = fs_open(path, "w", tonumber("644", 8)) if not fd then return nil, err end -- A single `fs_write` may short-write; loop until all bytes land. local offset = 0 while offset < #content do local werr, n = fs_write(fd, content:sub(offset + 1), offset) if not n then fs_close(fd); return nil, werr end offset = offset + n end -- Empty content still needs the truncate that O_TRUNC gave us. fs_close(fd) return true, nil end 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 -- 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 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 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 tool