summaryrefslogtreecommitdiff
path: root/agent/tools/_std.lua
diff options
context:
space:
mode:
Diffstat (limited to 'agent/tools/_std.lua')
-rw-r--r--agent/tools/_std.lua228
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