From 48651e123ef0cf1d02eac781902517c0628b310a Mon Sep 17 00:00:00 2001 From: T Date: Wed, 27 May 2026 17:07:49 -0600 Subject: real coding agent tools --- agent/tools/shell.lua | 397 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 397 insertions(+) create mode 100644 agent/tools/shell.lua (limited to 'agent/tools/shell.lua') diff --git a/agent/tools/shell.lua b/agent/tools/shell.lua new file mode 100644 index 0000000..c615d1c --- /dev/null +++ b/agent/tools/shell.lua @@ -0,0 +1,397 @@ +-- Run a shell command and return its merged stdout+stderr output. +-- +-- The command is executed via `/bin/sh -c `, so shell +-- features (pipes, redirections, globs, `&&`, etc.) all work the +-- usual way. Stdin is closed; the command cannot read from the +-- terminal. +-- +-- Context-budget discipline: +-- - Output is capped at 50 KB (matching `read`'s cap), keeping the +-- **last** 50 KB on overflow. Compiler errors, test failures, and +-- stack traces all put the important content at the end, so a +-- 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 +-- specific regions — including the head that fell out of the +-- tail buffer. +-- +-- Timeout: defaults to 30s. When the timer fires the child gets +-- SIGTERM; if it still hasn't exited 250 ms later, SIGKILL. +-- +-- Stdout and stderr are merged into a single stream (in arrival +-- order). Distinguishing them would require either prefixing every +-- chunk (visually noisy) or surfacing two separate strings (not how +-- a string-returning tool result is shaped). Most shell commands the +-- agent runs interleave the two anyway. + +local uv = require("luv") + +local MAX_BYTES = 50 * 1024 -- 50 KB, same as `read` +local DEFAULT_TIMEOUT_S = 300 -- 5 minutes +local SIGKILL_GRACE_MS = 250 + +-- --------------------------------------------------------------------------- +-- 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: --.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 +-- --------------------------------------------------------------------------- + +return { + name = "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), + schema = { + type = "object", + properties = { + command = { type = "string", description = "Shell command. Passed to `sh -c` verbatim; pipes, redirections, etc. work." }, + cwd = { type = "string", description = "Working directory (default: cwd)." }, + timeout = { type = "integer", description = "Hard timeout in seconds (default: " .. tostring(DEFAULT_TIMEOUT_S) .. ").", minimum = 1 }, + }, + required = { "command" }, + }, + handler = function(input) + local command = input.command + if type(command) ~= "string" or command == "" then + return "Error: `command` must be a non-empty string." + end + local cwd = input.cwd + if cwd ~= nil and type(cwd) ~= "string" then + return "Error: `cwd` must be a string if provided." + end + local timeout_s = input.timeout or DEFAULT_TIMEOUT_S + local timeout_ms = timeout_s * 1000 + + local co = coroutine.running() + if not co then + -- Should never happen — panto always runs handlers in a + -- coroutine — but be defensive rather than silently hang. + return "Error: shell tool requires a coroutine scheduler " .. + "(internal bug; report to panto)." + end + + -- Pipes for the child's stdout/stderr. Stdin is `nil` in the + -- stdio table → child reads from /dev/null equivalent (luv + -- closes that fd in the child). + local stdout_pipe = uv.new_pipe(false) + local stderr_pipe = uv.new_pipe(false) + + -- Output accumulator: a FIFO of arrival-order chunks whose + -- total length is always ≤ MAX_BYTES. On overflow we evict + -- from the front (oldest bytes first) so the in-memory buffer + -- always holds the *tail* of the output. The full unabridged + -- transcript is mirrored to a spill file, opened lazily on + -- first overflow and pre-loaded with everything seen so far. + local chunks = {} -- FIFO of strings; total ≤ MAX_BYTES + local first = 1 -- index of oldest live chunk in `chunks` + local last = 0 -- index of newest live chunk + local total_bytes = 0 + local total_received = 0 -- everything we ever saw, pre-eviction + local overflowed = false -- true once a byte has been evicted + local spill_path = nil + local spill_handle = nil -- uv fd (integer) once opened + local spill_error = nil -- if opening/writing the spill file fails + local total_written_to_spill = 0 + + local function open_spill_and_seed() + -- Opens the spill file and writes every chunk currently + -- live in the FIFO. Called exactly once, the moment we + -- realize we're going to overflow. After this returns, + -- 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() + if not path then + spill_error = err + return + end + -- O_WRONLY | O_CREAT | O_TRUNC; mode 0644. + local fd, oerr = uv.fs_open(path, "w", tonumber("644", 8)) + if not fd then + spill_error = oerr or "unknown error opening spill file" + return + end + spill_path = path + spill_handle = fd + for i = first, last do + local c = chunks[i] + local n, werr = uv.fs_write(fd, c, -1) + if not n then + spill_error = werr or "spill write failed" + return + end + total_written_to_spill = total_written_to_spill + n + end + end + + local function spill_write(data) + if not spill_handle then return end + local n, werr = uv.fs_write(spill_handle, data, -1) + if not n then + spill_error = spill_error or werr or "spill write failed" + else + total_written_to_spill = total_written_to_spill + n + end + end + + local function append_chunk(data) + total_received = total_received + #data + + -- First time we'd cross MAX_BYTES: open the spill file + -- and seed it with everything we've buffered so far. From + -- here on we always dual-write (spill gets the unabridged + -- stream; chunks holds only the tail). + if not overflowed and total_bytes + #data > MAX_BYTES then + overflowed = true + open_spill_and_seed() + end + if overflowed then + spill_write(data) + end + + -- Append to the ring. If the new chunk alone is bigger + -- than MAX_BYTES, drop everything we have and keep just + -- its tail. Otherwise, evict whole front chunks until + -- there's room, then either trim the front-most or + -- append intact. + if #data >= MAX_BYTES then + -- Drop the whole FIFO; new chunk is the tail. + for i = first, last do chunks[i] = nil end + first = last + 1 + total_bytes = 0 + local trimmed = data:sub(#data - MAX_BYTES + 1) + last = last + 1 + chunks[last] = trimmed + total_bytes = #trimmed + return + end + + while total_bytes + #data > MAX_BYTES and first <= last do + local front = chunks[first] + if total_bytes - #front + #data <= MAX_BYTES then + -- Trim the front chunk: drop only as many bytes + -- as needed to make room. + local need_to_drop = (total_bytes + #data) - MAX_BYTES + chunks[first] = front:sub(need_to_drop + 1) + total_bytes = total_bytes - need_to_drop + else + -- Drop the entire front chunk. + chunks[first] = nil + total_bytes = total_bytes - #front + first = first + 1 + end + end + last = last + 1 + chunks[last] = data + total_bytes = total_bytes + #data + end + + -- Coordination state. We resume the coroutine exactly once, + -- after `pending_events` hits zero: stdout EOF, stderr EOF, + -- on_exit fired = 3 events. + local pending_events = 3 + local resumed = false + local exit_code = nil + local exit_signal = nil + local timed_out = false + local spawn_error = nil + local process_handle = nil + local timeout_timer = nil + local kill_timer = nil + + local function buffer_to_string() + local out = {} + local oi = 0 + for i = first, last do + oi = oi + 1 + out[oi] = chunks[i] + end + return table.concat(out) + end + + local function try_resume() + if pending_events > 0 then return end + if resumed then return end + resumed = true + -- Cancel any still-pending timers. They may have been + -- nil'd out already inside their own callback. + if timeout_timer and not timeout_timer:is_closing() then + timeout_timer:stop(); timeout_timer:close() + end + if kill_timer and not kill_timer:is_closing() then + kill_timer:stop(); kill_timer:close() + end + local ok, err = coroutine.resume(co) + if not ok then + -- The handler shouldn't be raising here; this is the + -- resume scheduler, not user code. Log via stderr; + -- the parent will surface the failure as a tool error. + io.stderr:write("shell tool: resume failed: " .. tostring(err) .. "\n") + end + end + + local function on_read(err, data) + if err then + -- Treat read errors as EOF — luv signals EOF by + -- calling with `data == nil`, so an actual `err` means + -- something genuinely broke. Record it once. + append_chunk("[read error: " .. tostring(err) .. "]\n") + pending_events = pending_events - 1 + try_resume() + return + end + if data == nil then + -- EOF on this pipe. + pending_events = pending_events - 1 + try_resume() + return + end + append_chunk(data) + end + + local stdio = { nil, stdout_pipe, stderr_pipe } + local opts = { args = { "-c", command }, stdio = stdio } + if cwd then opts.cwd = cwd end + + local handle, pid_or_err, name = uv.spawn("/bin/sh", opts, function(code, signal) + exit_code = code + exit_signal = signal + pending_events = pending_events - 1 + -- The on-exit callback is responsible for closing the + -- process handle, per luv convention. + if process_handle and not process_handle:is_closing() then + process_handle:close() + end + try_resume() + end) + + if not handle then + -- Spawn failed before we ever started — `pid_or_err` is + -- the error message. Clean up pipes and return. + spawn_error = tostring(pid_or_err) .. + (name and (" (" .. name .. ")") or "") + stdout_pipe:close(); stderr_pipe:close() + return "Error: failed to spawn shell: " .. spawn_error + end + process_handle = handle + + stdout_pipe:read_start(on_read) + stderr_pipe:read_start(on_read) + + -- Timeout timer. If it fires, we send SIGTERM and arm a + -- second short timer to follow up with SIGKILL. + timeout_timer = uv.new_timer() + timeout_timer:start(timeout_ms, 0, function() + timed_out = true + if process_handle and not process_handle:is_closing() then + process_handle:kill("sigterm") + end + timeout_timer:stop(); timeout_timer:close(); timeout_timer = nil + kill_timer = uv.new_timer() + kill_timer:start(SIGKILL_GRACE_MS, 0, function() + if process_handle and not process_handle:is_closing() then + process_handle:kill("sigkill") + end + kill_timer:stop(); kill_timer:close(); kill_timer = nil + end) + end) + + -- Wait for stdout EOF, stderr EOF, and on_exit. + coroutine.yield() + + -- Close pipes (idempotent if already closing). + if not stdout_pipe:is_closing() then stdout_pipe:close() end + if not stderr_pipe:is_closing() then stderr_pipe:close() end + if spill_handle then uv.fs_close(spill_handle) end + + -- Build the result. + local header + if timed_out then + header = string.format( + "[shell] timed out after %ds; killed (exit code=%s signal=%s)", + timeout_s, tostring(exit_code or "?"), tostring(exit_signal or "?") + ) + elseif exit_signal and exit_signal ~= 0 then + header = string.format( + "[shell] terminated by signal %s (exit code=%s)", + tostring(exit_signal), tostring(exit_code or "?") + ) + else + header = string.format("[shell] exit code %d", exit_code or -1) + end + + local body = buffer_to_string() + local parts = { header, "", body } + if overflowed then + if spill_path and not spill_error then + parts[#parts + 1] = string.format( + "\n[truncated: kept last %d bytes; full %d-byte transcript " .. + "saved to %s. Use `read` with `start_line`/`end_line` " .. + "to inspect specific regions.]", + MAX_BYTES, total_written_to_spill, spill_path + ) + else + parts[#parts + 1] = string.format( + "\n[truncated: kept last %d bytes; could NOT spill to disk: %s]", + MAX_BYTES, spill_error or "unknown error" + ) + end + end + return table.concat(parts, "\n") + end, +} -- cgit v1.3