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.lua100
1 files changed, 81 insertions, 19 deletions
diff --git a/agent/tools/edit.lua b/agent/tools/edit.lua
index 7ad39c2..ec96aa6 100644
--- a/agent/tools/edit.lua
+++ b/agent/tools/edit.lua
@@ -10,6 +10,82 @@
-- All matches are evaluated against the original file, not
-- progressively against the result of earlier entries.
+local uv = require("luv")
+
+-- ---------------------------------------------------------------------------
+-- 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
+
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.",
@@ -45,14 +121,9 @@ return {
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()
+ local original, read_err = read_all(path)
if not original then
- return "Error: failed to read " .. path
+ return "Error: " .. (read_err or ("could not read " .. path))
end
-- Validate every entry first, collecting the byte-range each
@@ -170,19 +241,10 @@ return {
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)
+ local ok, write_err = write_all(path, 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)
+ return "Error: file read OK but write failed: " ..
+ tostring(write_err)
end
return string.format(