From b6afac69b586dd9d68cccf686f3220581848c78b Mon Sep 17 00:00:00 2001 From: t Date: Mon, 1 Jun 2026 13:47:35 -0600 Subject: rework lua tool scheduler --- agent/tools/edit.lua | 100 +++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 81 insertions(+), 19 deletions(-) (limited to 'agent/tools/edit.lua') 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( -- cgit v1.3