summaryrefslogtreecommitdiff
path: root/agent
diff options
context:
space:
mode:
Diffstat (limited to 'agent')
-rw-r--r--agent/README.md6
-rw-r--r--agent/extensions/std_render.lua206
-rw-r--r--agent/tools/_std.lua228
-rw-r--r--agent/tools/edit.lua93
-rw-r--r--agent/tools/read.lua108
-rw-r--r--agent/tools/shell.lua121
-rw-r--r--agent/tools/write.lua89
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