diff options
Diffstat (limited to 'agent')
| -rw-r--r-- | agent/README.md | 6 | ||||
| -rw-r--r-- | agent/extensions/std_render.lua | 206 | ||||
| -rw-r--r-- | agent/tools/_std.lua | 228 | ||||
| -rw-r--r-- | agent/tools/edit.lua | 93 | ||||
| -rw-r--r-- | agent/tools/read.lua | 108 | ||||
| -rw-r--r-- | agent/tools/shell.lua | 121 | ||||
| -rw-r--r-- | agent/tools/write.lua | 89 |
7 files changed, 326 insertions, 525 deletions
diff --git a/agent/README.md b/agent/README.md index 44d31fe..fc7f046 100644 --- a/agent/README.md +++ b/agent/README.md @@ -2,8 +2,8 @@ Contents of this directory are embedded into the `panto` binary at build time and staged onto disk at bootstrap into -`$PANTO_HOME/agent/` (i.e. `$XDG_DATA_HOME/panto/agent/`, or -`~/.local/share/panto/agent/` if `XDG_DATA_HOME` is unset). +`$XDG_DATA_HOME/panto/agent/`, or `~/.local/share/panto/agent/` if +`XDG_DATA_HOME` is unset. The staged tree is the "base" layer of panto's extension/tool search path — sitting underneath the user layer @@ -25,4 +25,4 @@ shadows the base copy). agent/ tools/ single-file tools, one per `.lua` - extensions/ (future) multi-tool extensions + extensions/ multi-tool extensions diff --git a/agent/extensions/std_render.lua b/agent/extensions/std_render.lua deleted file mode 100644 index 43a2c86..0000000 --- a/agent/extensions/std_render.lua +++ /dev/null @@ -1,206 +0,0 @@ --- Rendering components for the bundled std.* tools. --- --- The Zig TUI core deliberately renders tools generically. Tool-specific --- presentation belongs beside the tool implementation and is installed through --- the extension event system. - -local panto = require("panto") - -local ok_json, json = pcall(require, "dkjson") -if not ok_json then - ok_json, json = pcall(require, "luarocks.vendor.dkjson") -end - -local states_by_index = {} -local states_by_id = {} -local index_by_id = {} -local collapsed_tail_lines = 8 - -local function decode(s) - if type(s) ~= "string" or s == "" or not ok_json then return nil end - local ok, obj = pcall(json.decode, s) - if ok and type(obj) == "table" then return obj end - return nil -end - -local function short_name(name) - if type(name) ~= "string" then return nil end - return name:match("^std%.(.+)$") or name:match("^std__([%w_%-]+)$") or name -end - -local function is_ours(name) - local s = short_name(name) - return s == "read" or s == "write" or s == "shell" or s == "edit" -end - -local function spaces(n) return string.rep(" ", math.max(0, n or 0)) end - -local function wrap_text(text, width) - width = math.max(1, width or 1) - text = tostring(text or "") - local out = {} - for raw in (text .. "\n"):gmatch("(.-)\n") do - if raw == "" then - out[#out + 1] = "" - else - local line = raw - while #line > width do - local cut = width - local prefix = line:sub(1, width) - local sp = prefix:match("^.*() %S") - if sp and sp > 1 then cut = sp - 1 end - out[#out + 1] = line:sub(1, cut) - line = line:sub(cut + 1):gsub("^%s+", "") - end - out[#out + 1] = line - end - end - if #out == 0 then out[1] = "" end - return out -end - -local function quote(v) - if v == nil or v == "" then return nil end - return tostring(v) -end - -local function derive_header(st) - local name = short_name(st.name) or st.name or "?" - local obj = decode(st.input) - if name == "read" and obj then - local path = quote(obj.path) - if path then - if type(obj.offset) == "number" and type(obj.limit) == "number" then - return string.format("read %s (lines %d-%d)", path, obj.offset, obj.offset + obj.limit - 1) - elseif type(obj.offset) == "number" then - return string.format("read %s (from line %d)", path, obj.offset) - end - return "read " .. path - end - elseif name == "write" and obj and quote(obj.path) then - return "write " .. quote(obj.path) - elseif name == "shell" and obj and quote(obj.command) then - local tail = "" - if quote(obj.cwd) then tail = tail .. " cwd=" .. quote(obj.cwd) end - if type(obj.timeout) == "number" then tail = tail .. string.format(" timeout=%ds", obj.timeout) end - return "shell $ " .. quote(obj.command) .. tail - elseif name == "edit" and obj and quote(obj.path) then - local n = type(obj.edits) == "table" and #obj.edits or 0 - if n == 1 then return "edit " .. quote(obj.path) .. " (1 edit)" end - if n > 1 then return string.format("edit %s (%d edits)", quote(obj.path), n) end - return "edit " .. quote(obj.path) - end - return string.format("tool (%s) %s", st.name or "?", st.input or "") -end - -local function header(st) - return st.header_text or derive_header(st) -end - -local function edit_diff(input) - local obj = decode(input) - if not obj or type(obj.edits) ~= "table" then return nil end - local out = {} - for _, e in ipairs(obj.edits) do - if type(e) == "table" then - if type(e.old) == "string" then - for l in (e.old .. "\n"):gmatch("(.-)\n") do if l ~= "" then out[#out + 1] = "-" .. l end end - end - if type(e.new) == "string" then - for l in (e.new .. "\n"):gmatch("(.-)\n") do if l ~= "" then out[#out + 1] = "+" .. l end end - end - end - end - return table.concat(out, "\n") -end - -local function component(st) - return { - render = function(_, width) - local inner = math.max(1, (width or 1) - 2) - local lines = { "" } - local function add(s) - for _, l in ipairs(wrap_text(s, inner)) do - lines[#lines + 1] = " " .. l .. spaces(inner - #l + 1) - end - end - add(header(st)) - lines[#lines + 1] = " " .. spaces(inner) .. " " - local body - if short_name(st.name) == "edit" and st.output then - body = edit_diff(st.input) - end - body = body or st.output or "(…)" - local body_lines = wrap_text(body, inner) - local start = 1 - if st.collapsed and #body_lines > collapsed_tail_lines then - add("…") - start = #body_lines - collapsed_tail_lines + 1 - end - for i = start, #body_lines do - local l = body_lines[i] - lines[#lines + 1] = " " .. l .. spaces(inner - #l + 1) - end - lines[#lines + 1] = "" - return lines - end, - } -end - -local function state_for_event(e) - local idx = e.index - if not idx and e.id then idx = index_by_id[e.id] end - - local st = nil - if e.id then st = states_by_id[e.id] end - if not st and idx then st = states_by_index[idx] end - - -- Block indexes are per-turn/per-message stream positions and are reused - -- across later assistant turns. Tool-call ids, however, are unique for the - -- actual call. If a new call reuses an old index, discard the old render - -- state; otherwise the pending component temporarily shows the previous - -- tool's output until the new result arrives. - if st and e.id and st.id and st.id ~= e.id then - st = nil - end - - if not st then - st = { index = idx, input = "" } - end - if idx then - st.index = idx - states_by_index[idx] = st - end - if e.tool_name then st.name = e.tool_name end - if e.id then - st.id = e.id - index_by_id[e.id] = idx or st.index - states_by_id[e.id] = st - end - if e.input then st.input = e.input end - if e.delta then st.input = (st.input or "") .. e.delta end - if e.output then st.output = e.output end - if e.collapsed ~= nil then st.collapsed = e.collapsed end - if st.collapsed == nil then st.collapsed = true end - if st.name or st.input ~= "" then st.header_text = derive_header(st) end - return st -end - -local function on_tool_event(e) - local name = e.tool_name - if not name and e.id then - local idx = index_by_id[e.id] - local st = idx and states_by_index[idx] - name = st and st.name - end - if not is_ours(name) then return end - local st = state_for_event(e) - if not st then return end - e:set_component(component(st)) -end - -panto.ext.on("tool_details", on_tool_event) -panto.ext.on("tool_delta", on_tool_event) -panto.ext.on("tool_call_complete", on_tool_event) -panto.ext.on("tool_result", on_tool_event) -panto.ext.on("tool_collapse", on_tool_event) diff --git a/agent/tools/_std.lua b/agent/tools/_std.lua new file mode 100644 index 0000000..637b681 --- /dev/null +++ b/agent/tools/_std.lua @@ -0,0 +1,228 @@ +local panto = require("panto") +local uv = require("luv") + +local M = {} + +function M.await(label, arm) + local co = assert(coroutine.running(), label .. ": await must run inside a tool handler coroutine") + local res, fired = nil, false + arm(function(...) + assert(not fired, label .. ": 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 + +function M.await_n(label, n, arm) + local co = assert(coroutine.running(), label .. ": await_n must run inside a tool handler coroutine") + local remaining, resumed = n, false + local function signal() + if remaining <= 0 then return end + remaining = remaining - 1 + if remaining > 0 or resumed then return end + resumed = true + local ok, err = coroutine.resume(co) + if not ok then io.stderr:write(label .. " tool: resume failed: " .. tostring(err) .. "\n") end + end + arm(signal) + coroutine.yield() +end + +function M.fs_stat(path) + return M.await("fs_stat", function(resolve) uv.fs_stat(path, resolve) end) +end + +function M.fs_open(path, flags, mode) + return M.await("fs_open", function(resolve) uv.fs_open(path, flags, mode, resolve) end) +end + +function M.fs_fstat(fd) + return M.await("fs_fstat", function(resolve) uv.fs_fstat(fd, resolve) end) +end + +function M.fs_read(fd, size, offset) + return M.await("fs_read", function(resolve) uv.fs_read(fd, size, offset, resolve) end) +end + +function M.fs_write(fd, data, offset) + return M.await("fs_write", function(resolve) uv.fs_write(fd, data, offset, resolve) end) +end + +function M.fs_close(fd) + return M.await("fs_close", function(resolve) uv.fs_close(fd, resolve) end) +end + +function M.fs_mkdir(path, mode) + return M.await("fs_mkdir", function(resolve) uv.fs_mkdir(path, mode, resolve) end) +end + +function M.fs_opendir(path, batch) + return M.await("fs_opendir", function(resolve) uv.fs_opendir(path, resolve, batch) end) +end + +function M.fs_readdir(dir) + return M.await("fs_readdir", function(resolve) uv.fs_readdir(dir, resolve) end) +end + +function M.fs_closedir(dir) + return M.await("fs_closedir", function(resolve) uv.fs_closedir(dir, resolve) end) +end + +function M.dirname(path) + local slash = path:find("/[^/]*$") + if not slash or slash == 1 then return nil end + return path:sub(1, slash - 1) +end + +function M.mkdir_p_async(path) + if path == "" or path == "." or path == "/" then return true end + local cur = (path:sub(1, 1) == "/") and "" or "." + for piece in string.gmatch(path, "[^/]+") do + cur = cur .. "/" .. piece + local err = M.fs_mkdir(cur, tonumber("755", 8)) + if err and not tostring(err):find("EEXIST") then return nil, err end + end + return true +end + +function M.mkdir_p_sync(path) + if path == "" or path == "." or path == "/" then return true end + local cur = (path:sub(1, 1) == "/") and "" or "." + for piece in string.gmatch(path, "[^/]+") do + cur = cur .. "/" .. piece + local ok, err, name = uv.fs_mkdir(cur, tonumber("755", 8)) + if not ok and name ~= "EEXIST" then return nil, err end + end + return true +end + +function M.panto_home() + local xdg = os.getenv("XDG_DATA_HOME") + if xdg and xdg ~= "" then return xdg .. "/panto" end + local home = os.getenv("HOME") + if home and home ~= "" then return home .. "/.local/share/panto" end + return nil +end + +function M.fresh_spill_path() + local base = M.panto_home() + if not base then return nil, "no XDG_DATA_HOME / HOME in env" end + local dir = base .. "/shell-output" + local ok, err = M.mkdir_p_sync(dir) + if not ok then return nil, err end + local sec, usec = uv.gettimeofday() + return string.format("%s/%d-%06d-%d.txt", dir, sec, usec, uv.os_getpid()) +end + +local ok_json, json = pcall(require, "dkjson") +if not ok_json then + ok_json, json = pcall(require, "luarocks.vendor.dkjson") +end + +function M.decode(s) + if type(s) ~= "string" or s == "" or not ok_json then return nil end + local ok, obj = pcall(json.decode, s) + if ok and type(obj) == "table" then return obj end + return nil +end + +local states_by_index = {} +local states_by_id = {} +local index_by_id = {} +local collapsed_tail_lines = 8 + +local function spaces(n) return string.rep(" ", math.max(0, n or 0)) end + +local function wrap_text(text, width) + width = math.max(1, width or 1) + local out = {} + for raw in (tostring(text or "") .. "\n"):gmatch("(.-)\n") do + if raw == "" then + out[#out + 1] = "" + else + local line = raw + while #line > width do + local cut = width + local sp = line:sub(1, width):match("^.*() %S") + if sp and sp > 1 then cut = sp - 1 end + out[#out + 1] = line:sub(1, cut) + line = line:sub(cut + 1):gsub("^%s+", "") + end + out[#out + 1] = line + end + end + if #out == 0 then out[1] = "" end + return out +end + +local function component(st, body_fn) + return { + render = function(_, width) + local inner = math.max(1, (width or 1) - 2) + local lines = { "" } + local function add(s) + for _, l in ipairs(wrap_text(s, inner)) do + lines[#lines + 1] = " " .. l .. spaces(inner - #l + 1) + end + end + add(st.header_text or st.name or "tool") + lines[#lines + 1] = " " .. spaces(inner) .. " " + local body = (body_fn and body_fn(st)) or st.output or "(...)" + local body_lines = wrap_text(body, inner) + local start = 1 + if st.collapsed and #body_lines > collapsed_tail_lines then + add("...") + start = #body_lines - collapsed_tail_lines + 1 + end + for i = start, #body_lines do + local l = body_lines[i] + lines[#lines + 1] = " " .. l .. spaces(inner - #l + 1) + end + lines[#lines + 1] = "" + return lines + end, + } +end + +function M.install_renderer(tool_name, header_fn, body_fn) + local function state_for_event(e) + local idx = e.index + if not idx and e.id then idx = index_by_id[e.id] end + local st = (e.id and states_by_id[e.id]) or (idx and states_by_index[idx]) or nil + if st and e.id and st.id and st.id ~= e.id then st = nil end + if not st then st = { index = idx, input = "" } end + if idx then st.index = idx; states_by_index[idx] = st end + if e.tool_name then st.name = e.tool_name end + if e.id then st.id = e.id; index_by_id[e.id] = idx or st.index; states_by_id[e.id] = st end + if e.input then st.input = e.input end + if e.delta then st.input = (st.input or "") .. e.delta end + if e.output then st.output = e.output end + if e.collapsed ~= nil then st.collapsed = e.collapsed end + if st.collapsed == nil then st.collapsed = true end + st.header_text = header_fn and header_fn(st) or st.name + return st + end + + local function on_tool_event(e) + local name = e.tool_name + if not name and e.id then + local idx = index_by_id[e.id] + local st = idx and states_by_index[idx] + name = st and st.name + end + if name ~= tool_name then return end + e:set_component(component(state_for_event(e), body_fn)) + end + + panto.ext.on("tool_details", on_tool_event) + panto.ext.on("tool_delta", on_tool_event) + panto.ext.on("tool_call_complete", on_tool_event) + panto.ext.on("tool_result", on_tool_event) + panto.ext.on("tool_collapse", on_tool_event) +end + +return M diff --git a/agent/tools/edit.lua b/agent/tools/edit.lua index b32516a..2fa3468 100644 --- a/agent/tools/edit.lua +++ b/agent/tools/edit.lua @@ -10,8 +10,7 @@ -- 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 std = require("_std") local tool = { name = "std.edit", @@ -38,82 +37,39 @@ local tool = { }, } -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) + local err, fd = std.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 serr, st = std.fs_fstat(fd) + if not st then std.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 + local rerr, data = std.fs_read(fd, 64 * 1024, offset) + if rerr then std.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) + std.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)) + local err, fd = std.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 + local werr, n = std.fs_write(fd, content:sub(offset + 1), offset) + if not n then std.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) + std.fs_close(fd) return true, nil end @@ -261,4 +217,31 @@ tool.handler = function(input) ) end +local function edit_diff(input) + local obj = std.decode(input) + if not obj or type(obj.edits) ~= "table" then return nil end + local out = {} + for _, e in ipairs(obj.edits) do + if type(e) == "table" then + if type(e.old) == "string" then + for l in (e.old .. "\n"):gmatch("(.-)\n") do if l ~= "" then out[#out + 1] = "-" .. l end end + end + if type(e.new) == "string" then + for l in (e.new .. "\n"):gmatch("(.-)\n") do if l ~= "" then out[#out + 1] = "+" .. l end end + end + end + end + return table.concat(out, "\n") +end + +std.install_renderer(tool.name, function(st) + local obj = std.decode(st.input) + local n = obj and type(obj.edits) == "table" and #obj.edits or 0 + if n == 1 then return "edit " .. tostring(obj.path or "") .. " (1 edit)" end + if n > 1 then return string.format("edit %s (%d edits)", tostring(obj.path or ""), n) end + return "edit " .. tostring(obj and obj.path or "") +end, function(st) + return edit_diff(st.input) or st.output +end) + return tool diff --git a/agent/tools/read.lua b/agent/tools/read.lua index f691f44..cd7f26b 100644 --- a/agent/tools/read.lua +++ b/agent/tools/read.lua @@ -1,5 +1,4 @@ -local panto = require("panto") -local uv = require("luv") +local std = require("_std") local tool = { name = "std.read", @@ -15,11 +14,6 @@ local tool = { }, } -panto.ext.on("tool", function(e) - if e.tool_name ~= tool.name then return end - e:set_component(e:get_component()) -end) - -- Read a file from disk and return its text verbatim. -- -- Output cap: 50 KB and 2000 lines, whichever is hit first. Large @@ -63,71 +57,10 @@ local MAX_BYTES = 50 * 1024 -- 50 KB local MAX_LINES = 2000 local READ_CHUNK_SIZE = MAX_BYTES --- --------------------------------------------------------------------------- --- Coroutine-synchronous libuv wrappers. --- --- panto runs every tool handler inside its own Lua coroutine and drives --- a single `uv.run()` to completion. The contract for an async handler --- is: the ONLY way it may block is to yield on a pending libuv request --- whose callback resumes it exactly once. These helpers encapsulate --- that pattern — each fires one async libuv op, yields, and returns the --- callback's results once resumed. They look synchronous to the caller --- but cooperate with sibling tool calls sharing the event loop. --- --- INVARIANTS each wrapper upholds (and every async tool must too): --- * exactly one libuv op armed per yield; --- * the callback resumes the coroutine exactly once; --- * `coroutine.resume` errors are re-raised so a crash in the resumed --- handler isn't silently swallowed inside the uv callback. --- --------------------------------------------------------------------------- - --- Run a single callback-style libuv fs operation and block the --- coroutine until its callback fires. `arm` receives a `resolve` --- function and must start exactly one libuv op that calls it. -local function await(arm) - local co = assert(coroutine.running(), - "read: await must run inside a tool handler coroutine") - local res, fired = nil, false - arm(function(...) - assert(not fired, "read: 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_stat(path) - return await(function(resolve) uv.fs_stat(path, resolve) end) -end - -local function fs_open(path, flags, mode) - return await(function(resolve) uv.fs_open(path, flags, mode, 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_close(fd) - return await(function(resolve) uv.fs_close(fd, resolve) end) -end - -- `uv.fs_opendir` takes the entries-per-readdir batch hint as its -- SECOND positional arg, before the callback. We pass it explicitly so -- each `fs_readdir` returns up to `n` entries per round-trip. local READDIR_BATCH = 256 -local function fs_opendir(path) - return await(function(resolve) uv.fs_opendir(path, resolve, READDIR_BATCH) end) -end -local function fs_readdir(dir) - return await(function(resolve) uv.fs_readdir(dir, resolve) end) -end -local function fs_closedir(dir) - return await(function(resolve) uv.fs_closedir(dir, resolve) end) -end local function scandir_sorted(path) -- Use the streaming opendir/readdir/closedir API rather than @@ -135,16 +68,16 @@ local function scandir_sorted(path) -- with synchronous `readdir(3)` syscalls, which would block the -- shared event loop on large directories. opendir/readdir route -- every batch through libuv's thread pool, keeping us cooperative. - local err, dir = fs_opendir(path) + local err, dir = std.fs_opendir(path, READDIR_BATCH) if not dir then return nil, err end local entries = {} while true do - local rerr, batch = fs_readdir(dir) + local rerr, batch = std.fs_readdir(dir) if rerr then - fs_closedir(dir) + std.fs_closedir(dir) return nil, rerr end -- A nil/empty batch signals end of the directory stream. @@ -154,7 +87,7 @@ local function scandir_sorted(path) entries[#entries + 1] = { name = e.name, typ = e.type } end end - fs_closedir(dir) + std.fs_closedir(dir) table.sort(entries, function(a, b) return a.name < b.name @@ -248,7 +181,7 @@ tool.handler = function(input) return string.format("Error: limit (%d) must be >= 1.", limit) end - local stat_err, stat = fs_stat(path) + local stat_err, stat = std.fs_stat(path) if not stat then return "Error: " .. (stat_err or ("could not stat " .. path)) end @@ -256,7 +189,7 @@ tool.handler = function(input) return render_directory_listing(path, offset, limit) end - local open_err, fd = fs_open(path, "r", 0) + local open_err, fd = std.fs_open(path, "r", 0) if not fd then return "Error: " .. (open_err or ("could not open " .. path)) end @@ -266,13 +199,13 @@ tool.handler = function(input) -- it's an attachment, we return the raw file bytes; libpanto does -- the real work (precise type detection, resizing, encoding). do - local herr, header = fs_read(fd, 16, 0) + local herr, header = std.fs_read(fd, 16, 0) if not herr and header and #header > 0 and is_attachment(header) then local whole, rerr = (function() local chunks = {} local off = 0 while true do - local e, d = fs_read(fd, READ_CHUNK_SIZE, off) + local e, d = std.fs_read(fd, READ_CHUNK_SIZE, off) if e then return nil, e end if d == nil or #d == 0 then break end chunks[#chunks + 1] = d @@ -280,7 +213,7 @@ tool.handler = function(input) end return table.concat(chunks) end)() - fs_close(fd) + std.fs_close(fd) if not whole then return "Error: read failed: " .. tostring(rerr) end @@ -332,7 +265,7 @@ tool.handler = function(input) end while not done do - local err, data = fs_read(fd, READ_CHUNK_SIZE, file_offset) + local err, data = std.fs_read(fd, READ_CHUNK_SIZE, file_offset) if err then read_err = err break @@ -372,7 +305,7 @@ tool.handler = function(input) end end - fs_close(fd) + std.fs_close(fd) if read_err then return "Error: read failed: " .. tostring(read_err) @@ -418,4 +351,21 @@ tool.handler = function(input) return body end +local function quote(v) + if v == nil or v == "" then return nil end + return tostring(v) +end + +std.install_renderer(tool.name, function(st) + local obj = std.decode(st.input) + local path = obj and quote(obj.path) + if not path then return "read" end + if type(obj.offset) == "number" and type(obj.limit) == "number" then + return string.format("read %s (lines %d-%d)", path, obj.offset, obj.offset + obj.limit - 1) + elseif type(obj.offset) == "number" then + return string.format("read %s (from line %d)", path, obj.offset) + end + return "read " .. path +end) + return tool diff --git a/agent/tools/shell.lua b/agent/tools/shell.lua index 6c0a37c..c325946 100644 --- a/agent/tools/shell.lua +++ b/agent/tools/shell.lua @@ -12,8 +12,8 @@ -- head-keeping shell tool routinely truncates exactly the bytes -- the agent needed. -- - Once overflow is detected, the *complete* transcript is --- streamed to a spill file under `$PANTO_HOME/shell-output/`. The --- agent can then `read` it with start_line/end_line to inspect +-- streamed to a spill file under the panto data home's `shell-output/`. +-- The agent can then `read` it with start_line/end_line to inspect -- specific regions — including the head that fell out of the -- tail buffer. -- @@ -26,7 +26,7 @@ -- a string-returning tool result is shaped). Most shell commands the -- agent runs interleave the two anyway. -local panto = require("panto") +local std = require("_std") local uv = require("luv") local MAX_BYTES = 50 * 1024 -- 50 KB, same as `read` @@ -35,7 +35,7 @@ local SIGKILL_GRACE_MS = 250 local tool = { name = "std.shell", - description = string.format("Execute a shell command via `/bin/sh -c`. Returns merged stdout+stderr after the command exits, prefixed with an exit-status header. Output is truncated to the last %dKB; on truncation the full transcript is saved to `$PANTO_HOME/shell-output/` and its path is included for follow-up `read` calls. Default timeout 5 minutes.", MAX_BYTES / 1024), + description = string.format("Execute a shell command via `/bin/sh -c`. Returns merged stdout+stderr after the command exits, prefixed with an exit-status header. Output is truncated to the last %dKB; on truncation the full transcript is saved under the panto data home's `shell-output/` and its path is included for follow-up `read` calls. Default timeout 5 minutes.", MAX_BYTES / 1024), schema = { type = "object", properties = { @@ -47,104 +47,6 @@ local tool = { }, } -panto.ext.on("tool", function(e) - if e.tool_name ~= tool.name then return end - e:set_component(e:get_component()) -end) - --- --------------------------------------------------------------------------- --- Coroutine-synchronous coordination. --- --- panto runs every tool handler in its own coroutine and drives a --- single `uv.run()` to completion; the only legal way for a handler to --- block is to yield on pending libuv work whose callback(s) resume it. --- The shell tool waits on THREE independent events before it can build --- its result: stdout EOF, stderr EOF, and process exit. `await_n` --- captures that "resume once, after N signals" pattern. --- --- `arm(signal)` is called immediately with a `signal` function; it must --- arrange for `signal` to be called exactly `n` times across its libuv --- callbacks. After the n-th signal, the coroutine is resumed exactly --- once. `signal` is safe to call from any libuv callback. -local function await_n(n, arm) - local co = assert(coroutine.running(), - "shell: await_n must run inside a tool handler coroutine") - local remaining, resumed = n, false - local function signal() - if remaining <= 0 then return end - remaining = remaining - 1 - if remaining > 0 or resumed then return end - resumed = true - local ok, err = coroutine.resume(co) - if not ok then - -- This runs inside a libuv callback, not the handler's - -- own frame; re-raising here would unwind the event loop. - -- Log instead; the scheduler surfaces the unfinished - -- coroutine as a tool error on reap. - io.stderr:write("shell tool: resume failed: " .. tostring(err) .. "\n") - end - end - arm(signal) - coroutine.yield() -end - --- --------------------------------------------------------------------------- --- Spill-file path resolution. We mirror Zig's `panto_home.resolveHome`: --- PANTO_HOME wins, then XDG_DATA_HOME/panto, then HOME/.local/share/panto. --- The shell-output subdirectory is created lazily. --- --------------------------------------------------------------------------- - -local function panto_home() - local p = os.getenv("PANTO_HOME") - if p and p ~= "" then return p end - local xdg = os.getenv("XDG_DATA_HOME") - if xdg and xdg ~= "" then return xdg .. "/panto" end - local home = os.getenv("HOME") - if home and home ~= "" then return home .. "/.local/share/panto" end - return nil -end - -local function spill_dir() - local base = panto_home() - if not base then return nil end - return base .. "/shell-output" -end - --- `mkdir -p`-equivalent via luv. We can't shell out to mkdir here — --- that would be circular — so we walk the path components and call --- `uv.fs_mkdir` on each, ignoring EEXIST. Synchronous; this is a --- once-per-spill cost. -local function mkdir_p(path) - -- Split on `/` and rebuild. Absolute paths start with "/"; preserve. - local parts = {} - for piece in string.gmatch(path, "[^/]+") do - parts[#parts + 1] = piece - end - local cur = (path:sub(1, 1) == "/") and "" or "." - for _, piece in ipairs(parts) do - cur = cur .. "/" .. piece - local ok, err, name = uv.fs_mkdir(cur, tonumber("755", 8)) - if not ok and name ~= "EEXIST" then - return nil, err - end - end - return true -end - -local function fresh_spill_path() - local dir = spill_dir() - if not dir then return nil, "no PANTO_HOME / XDG_DATA_HOME / HOME in env" end - local ok, err = mkdir_p(dir) - if not ok then return nil, err end - -- Filename: <unix-seconds>-<microseconds>-<pid>.txt. The pid is - -- the parent panto process; collision avoidance against concurrent - -- tool calls comes from the microsecond component (uv.hrtime() is - -- nanoseconds since some epoch). - local sec, usec = uv.gettimeofday() - local pid = uv.os_getpid() - return string.format("%s/%d-%06d-%d.txt", dir, sec, usec, pid) -end - -- --------------------------------------------------------------------------- -- Tool body -- --------------------------------------------------------------------------- @@ -205,7 +107,7 @@ tool.handler = function(input) -- the spill file contains the entire history; every -- subsequent chunk just appends. if spill_handle or spill_error then return end - local path, err = fresh_spill_path() + local path, err = std.fresh_spill_path() if not path then spill_error = err return @@ -338,7 +240,7 @@ tool.handler = function(input) -- `await_n(3, ...)` resumes this coroutine exactly once, after -- all three of {stdout EOF, stderr EOF, process exit} signal. - await_n(3, function(signal) + std.await_n("shell", 3, function(signal) -- The exit callback was armed above (before this awaiter -- existed); wire its signal now. on_exit_signal = signal @@ -434,6 +336,15 @@ tool.handler = function(input) end end return table.concat(parts, "\n") - end +end + +std.install_renderer(tool.name, function(st) + local obj = std.decode(st.input) + if not obj or type(obj.command) ~= "string" then return "shell" end + local tail = "" + if type(obj.cwd) == "string" and obj.cwd ~= "" then tail = tail .. " cwd=" .. obj.cwd end + if type(obj.timeout) == "number" then tail = tail .. string.format(" timeout=%ds", obj.timeout) end + return "shell $ " .. obj.command .. tail +end) return tool diff --git a/agent/tools/write.lua b/agent/tools/write.lua index 144a305..ee1b2ae 100644 --- a/agent/tools/write.lua +++ b/agent/tools/write.lua @@ -2,8 +2,7 @@ -- description terse because every byte of every tool description rides -- in the system prompt on every turn. -local panto = require("panto") -local uv = require("luv") +local std = require("_std") local tool = { name = "std.write", @@ -18,75 +17,6 @@ local tool = { }, } -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. --- --------------------------------------------------------------------------- -local function await(arm) - local co = assert(coroutine.running(), - "write: await must run inside a tool handler coroutine") - local res, fired = nil, false - arm(function(...) - assert(not fired, "write: 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 - --- Async `fs_mkdir`. luv's async callback is `(err, success)`, and on --- EEXIST it reports the error via a string code in `err`; we detect --- that textually since the async form doesn't surface the errno name --- separately the way the sync form's third return does. -local function fs_mkdir(path, mode) - return await(function(resolve) uv.fs_mkdir(path, mode, resolve) end) -end -local function fs_open(path, flags, mode) - return await(function(resolve) uv.fs_open(path, flags, mode, 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 - --- mkdir -p equivalent: walk the path components and create each. --- Ignores "already exists" so the call is idempotent. -local function mkdir_p(path) - if path == "" or path == "." or path == "/" then return true end - local parts = {} - for piece in string.gmatch(path, "[^/]+") do - parts[#parts + 1] = piece - end - local cur = (path:sub(1, 1) == "/") and "" or "." - for _, piece in ipairs(parts) do - cur = cur .. "/" .. piece - local err = fs_mkdir(cur, tonumber("755", 8)) - if err and not tostring(err):find("EEXIST") then - return nil, err - end - end - return true -end - --- Return the dirname of `path`, or nil if there isn't one (basename only). -local function dirname(path) - local slash = path:find("/[^/]*$") - if not slash or slash == 1 then return nil end - return path:sub(1, slash - 1) -end - tool.handler = function(input) local path = input.path local content = input.content @@ -98,15 +28,15 @@ tool.handler = function(input) return "Error: `content` must be a string." end - local parent = dirname(path) + local parent = std.dirname(path) if parent then - local ok, err = mkdir_p(parent) + local ok, err = std.mkdir_p_async(parent) if not ok then return "Error: could not create parent directory " .. parent .. ": " .. tostring(err) end end - local open_err, fd = fs_open(path, "w", tonumber("644", 8)) + local open_err, fd = std.fs_open(path, "w", tonumber("644", 8)) if not fd then return "Error: " .. (open_err or ("could not open " .. path .. " for writing")) end @@ -114,15 +44,15 @@ tool.handler = function(input) -- 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) + local werr, n = std.fs_write(fd, content:sub(offset + 1), offset) if not n then - fs_close(fd) + std.fs_close(fd) return "Error: write failed: " .. tostring(werr) end offset = offset + n end - local close_err = fs_close(fd) + local close_err = std.fs_close(fd) if close_err then return "Error: close failed: " .. tostring(close_err) end @@ -130,4 +60,9 @@ tool.handler = function(input) return string.format("Wrote %d bytes to %s.", #content, path) end +std.install_renderer(tool.name, function(st) + local obj = std.decode(st.input) + return "write " .. tostring(obj and obj.path or "") +end) + return tool |
