From f8c6d45755acb5abf9ac68931268b7945da0fae0 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 15 Jun 2026 22:11:53 -0600 Subject: display fixes: markdown rendering, tool-specific components --- agent/tools/edit.lua | 299 ++++++++++++++++++++++++++------------------------- 1 file changed, 154 insertions(+), 145 deletions(-) (limited to 'agent/tools/edit.lua') 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) - - 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) + -- 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) - -- 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) + 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) - 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, -} + -- 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 -- cgit v1.3