summaryrefslogtreecommitdiff
path: root/agent/tools/shell.lua
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-23 09:38:23 -0600
committert <t@tjp.lol>2026-06-23 10:53:26 -0600
commit69a1ee138bb78ad4663fe2d9e58678f7b0d07b74 (patch)
tree453d3ad42a07536220e6ca5d91b439ff57793e32 /agent/tools/shell.lua
parent538a5d926fa626a00cc3dc12c555f11f82867efd (diff)
ponytail simplifications to panto cli and lua extensiosn
Diffstat (limited to 'agent/tools/shell.lua')
-rw-r--r--agent/tools/shell.lua121
1 files changed, 16 insertions, 105 deletions
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