diff options
Diffstat (limited to 'agent/tools/shell.lua')
| -rw-r--r-- | agent/tools/shell.lua | 121 |
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 |
