diff options
| author | t <t@tjp.lol> | 2026-06-23 09:38:23 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-23 10:53:26 -0600 |
| commit | 69a1ee138bb78ad4663fe2d9e58678f7b0d07b74 (patch) | |
| tree | 453d3ad42a07536220e6ca5d91b439ff57793e32 /agent/tools/_std.lua | |
| parent | 538a5d926fa626a00cc3dc12c555f11f82867efd (diff) | |
ponytail simplifications to panto cli and lua extensiosn
Diffstat (limited to 'agent/tools/_std.lua')
| -rw-r--r-- | agent/tools/_std.lua | 228 |
1 files changed, 228 insertions, 0 deletions
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 |
