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 --- .panto | 1 - agent/tools/edit.lua | 193 ++++++++++++++++++++++++ agent/tools/read.lua | 139 ++++++++++++++++++ agent/tools/shell.lua | 397 ++++++++++++++++++++++++++++++++++++++++++++++++++ agent/tools/write.lua | 81 ++++++++++ build.zig | 51 ++++++- src/lua_runtime.zig | 177 +++++++++++++++++++--- src/main.zig | 5 - src/ping_tool.zig | 59 -------- 9 files changed, 1012 insertions(+), 91 deletions(-) delete mode 120000 .panto create mode 100644 agent/tools/edit.lua create mode 100644 agent/tools/read.lua create mode 100644 agent/tools/shell.lua create mode 100644 agent/tools/write.lua delete mode 100644 src/ping_tool.zig diff --git a/.panto b/.panto deleted file mode 120000 index b726568..0000000 --- a/.panto +++ /dev/null @@ -1 +0,0 @@ -examples \ No newline at end of file diff --git a/agent/tools/edit.lua b/agent/tools/edit.lua new file mode 100644 index 0000000..a56c334 --- /dev/null +++ b/agent/tools/edit.lua @@ -0,0 +1,193 @@ +-- Apply a batch of exact-text replacements to a file. Pi-style terse +-- tool description; the rules are enforced here rather than re-stated +-- in prose, with the rejection message naming exactly which rule each +-- entry tripped so the model can self-correct on the next call. +-- +-- Rejection rules (atomic — any failure rejects the whole call): +-- 1. Each `old` must occur exactly once in the original file. +-- 2. `old` may not be the empty string. +-- 3. Resolved ranges of `old` matches must not overlap. +-- All matches are evaluated against the original file, not +-- progressively against the result of earlier entries. + +return { + name = "edit", + description = "Apply exact-text replacements to a file. Each entry's `old` must match exactly once in the original file (not progressively). Edits whose ranges overlap, match zero times, or match multiple times reject the entire call — no partial edits, file untouched. Keep `old` minimal but unique; widen with surrounding context if needed.", + schema = { + type = "object", + properties = { + path = { type = "string", description = "Path to the file (relative or absolute)." }, + edits = { + type = "array", + description = "Replacements applied atomically.", + minItems = 1, + items = { + type = "object", + properties = { + old = { type = "string", description = "Exact substring to find; must appear exactly once." }, + new = { type = "string", description = "Replacement text (may be empty to delete)." }, + }, + required = { "old", "new" }, + }, + }, + }, + required = { "path", "edits" }, + }, + handler = function(input) + local path = input.path + local edits = input.edits + + if type(path) ~= "string" or path == "" then + return "Error: `path` must be a non-empty string." + end + if type(edits) ~= "table" or #edits == 0 then + return "Error: `edits` must be a non-empty array." + end + + -- Load the file. + local f, open_err = io.open(path, "rb") + if not f then + return "Error: " .. (open_err or ("could not open " .. path)) + end + local original = f:read("a") + f:close() + if not original then + return "Error: failed to read " .. path + end + + -- Validate every entry first, collecting the byte-range each + -- `before` resolves to. We need this in two passes: + -- 1. uniqueness check (count occurrences) + -- 2. overlap check (compare resolved ranges pairwise) + local problems = {} -- list of "edit #N: ..." strings + local ranges = {} -- per-edit { start_byte, end_byte } or nil + + for i, e in ipairs(edits) do + if type(e) ~= "table" then + problems[#problems + 1] = string.format( + "edit #%d: entry is not an object", i + ) + ranges[i] = nil + elseif type(e.old) ~= "string" or type(e.new) ~= "string" then + problems[#problems + 1] = string.format( + "edit #%d: `old` and `new` must both be strings", i + ) + ranges[i] = nil + elseif e.old == "" then + problems[#problems + 1] = string.format( + "edit #%d: `old` is the empty string (would be ambiguous)", i + ) + ranges[i] = nil + else + -- string.find with `plain = true` skips pattern parsing, + -- so the search is literal. Count occurrences by walking. + local count = 0 + local first_start, first_end = nil, nil + local search_from = 1 + while true do + local s, ee = string.find(original, e.old, search_from, true) + if not s then break end + count = count + 1 + if count == 1 then + first_start, first_end = s, ee + end + search_from = ee + 1 + end + if count == 0 then + -- Truncate the previewed snippet so the error stays + -- legible in tool output. + local snippet = e.old + if #snippet > 80 then + snippet = snippet:sub(1, 77) .. "..." + end + problems[#problems + 1] = string.format( + "edit #%d: `old` not found in file. Snippet: %q", + i, snippet + ) + ranges[i] = nil + elseif count > 1 then + problems[#problems + 1] = string.format( + "edit #%d: `old` matches %d times (need exactly 1). " .. + "Widen with surrounding context to disambiguate.", + i, count + ) + ranges[i] = nil + else + ranges[i] = { first_start, first_end } + end + end + end + + -- Overlap check (only meaningful if we got this far without + -- per-edit problems; otherwise the user already has plenty to + -- fix and overlap noise would be confusing). + if #problems == 0 then + for i = 1, #ranges do + for j = i + 1, #ranges do + local a, b = ranges[i], ranges[j] + if a and b and not (a[2] < b[1] or b[2] < a[1]) then + problems[#problems + 1] = string.format( + "edits #%d and #%d target overlapping regions " .. + "(bytes %d-%d and %d-%d). Combine them into one " .. + "edit with a wider `old`.", + i, j, a[1], a[2], b[1], b[2] + ) + end + end + end + end + + if #problems > 0 then + return "Error: edit rejected (" .. #problems .. " issue" .. + (#problems == 1 and "" or "s") .. "); file not modified.\n" .. + " - " .. table.concat(problems, "\n - ") .. "\n" + end + + -- Apply edits. We sort by start position ascending and rebuild + -- the file from non-overlapping slices. This avoids the O(N*M) + -- repeated-substring-substitution and keeps each edit's + -- `before` matched against the original (not the in-progress + -- result). + local order = {} + for i = 1, #edits do order[i] = i end + table.sort(order, function(a, b) + return ranges[a][1] < ranges[b][1] + end) + + local out = {} + local cursor = 1 + for _, idx in ipairs(order) do + local r = ranges[idx] + if r[1] > cursor then + out[#out + 1] = original:sub(cursor, r[1] - 1) + end + out[#out + 1] = edits[idx].new + cursor = r[2] + 1 + end + if cursor <= #original then + out[#out + 1] = original:sub(cursor) + end + local new_content = table.concat(out) + + -- Write back. + local wf, werr = io.open(path, "wb") + if not wf then + return "Error: file read OK but failed to reopen for write: " .. + (werr or "?") + end + local ok, write_err = wf:write(new_content) + if not ok then + wf:close() + return "Error: write failed: " .. tostring(write_err) + end + local close_ok, close_err = wf:close() + if not close_ok then + return "Error: close failed: " .. tostring(close_err) + end + + return string.format( + "Applied %d edit%s to %s. File is now %d bytes (was %d).", + #edits, (#edits == 1 and "" or "s"), path, #new_content, #original + ) + end, +} diff --git a/agent/tools/read.lua b/agent/tools/read.lua new file mode 100644 index 0000000..e6192ae --- /dev/null +++ b/agent/tools/read.lua @@ -0,0 +1,139 @@ +-- Read a file from disk and return its text verbatim. +-- +-- Output cap: 50 KB and 2000 lines, whichever is hit first. Matches +-- pi's defaults; large enough to be useful, small enough to keep the +-- model's context manageable. A single tool result that blows out the +-- context window is worse than one that silently asks for a follow-up +-- scoped read. +-- +-- The slicing knobs are `offset` (1-based line to start at) and +-- `limit` (max lines to return). SQL-shaped — models recognize the +-- idiom — and matches pi's tool surface. When the cap is hit, a +-- `[truncated: ...]` marker names the next `offset` for a follow-up +-- call. The file body itself is byte-for-byte what's on disk, so +-- `edit` anchors round-trip exactly. + +local MAX_BYTES = 50 * 1024 -- 50 KB +local MAX_LINES = 2000 + +return { + name = "read", + description = "Read a file from disk. Output is truncated to the first 50KB or 2000 lines, whichever is hit first; on truncation a marker names the next offset for a follow-up call. Use offset/limit to slice.", + schema = { + type = "object", + properties = { + path = { type = "string", description = "Path to the file (relative or absolute)." }, + offset = { type = "integer", description = "1-based line to start at.", minimum = 1 }, + limit = { type = "integer", description = "Maximum number of lines to return.", minimum = 1 }, + }, + required = { "path" }, + }, + handler = function(input) + local path = input.path + local offset = input.offset or 1 + local limit = input.limit -- nil means "to EOF" + + if type(path) ~= "string" or path == "" then + return "Error: `path` must be a non-empty string." + end + if limit ~= nil and limit < 1 then + return string.format("Error: limit (%d) must be >= 1.", limit) + end + + local f, open_err = io.open(path, "rb") + if not f then + return "Error: " .. (open_err or ("could not open " .. path)) + end + + -- Walk lines one at a time so we can both apply start/end_line + -- and enforce MAX_BYTES / MAX_LINES without ever holding the + -- whole file in memory. + local parts = {} + local emitted_lines = 0 + local emitted_bytes = 0 + local lineno = 0 + local truncated_reason = nil -- nil, "bytes", or "lines" + local stopped_at_line = nil + + -- Effective line cap: min(limit, MAX_LINES). The MAX_LINES + -- cap is enforced regardless of what the caller passed, to + -- keep tool output context-budget-bounded. + local effective_line_cap = MAX_LINES + if limit and limit < effective_line_cap then + effective_line_cap = limit + end + + for line in f:lines("l") do + lineno = lineno + 1 + if lineno < offset then + -- Skip; haven't reached the window yet. + else + -- Re-add the newline `f:lines("l")` strips. For a + -- file with no trailing newline this still appends + -- one to the final line; that's a deliberate + -- normalization in our output, the on-disk file is + -- untouched. + local rendered = line .. "\n" + if emitted_bytes + #rendered > MAX_BYTES then + truncated_reason = "bytes" + stopped_at_line = lineno + break + end + parts[#parts + 1] = rendered + emitted_bytes = emitted_bytes + #rendered + emitted_lines = emitted_lines + 1 + if emitted_lines >= effective_line_cap then + -- Peek for at least one more line on disk so the + -- truncation marker only appears when there's + -- actually more to read. + local peek = f:read("l") + if peek then + -- "limit" if the *caller's* limit was the + -- binding cap, "lines" if our MAX_LINES + -- safety cap kicked in first. + truncated_reason = (limit and limit <= MAX_LINES) and "limit" or "lines" + stopped_at_line = lineno + end + break + end + end + end + f:close() + + if #parts == 0 then + if lineno == 0 then + return "(empty file)\n" + end + if offset > lineno then + return string.format( + "Error: offset (%d) is past end of file (%d lines).", + offset, lineno + ) + end + return "(no lines in requested range)\n" + end + + local body = table.concat(parts) + if truncated_reason == "bytes" then + body = body .. string.format( + "\n[truncated: hit %d-byte cap at line %d. " .. + "Call `read` again with `offset = %d` (and a smaller " .. + "`limit` if needed) to continue.]\n", + MAX_BYTES, stopped_at_line, stopped_at_line + ) + elseif truncated_reason == "lines" then + body = body .. string.format( + "\n[truncated: hit %d-line cap at line %d. " .. + "Call `read` again with `offset = %d` to continue.]\n", + MAX_LINES, stopped_at_line, stopped_at_line + 1 + ) + elseif truncated_reason == "limit" then + body = body .. string.format( + "\n[truncated: hit caller's `limit = %d` at line %d. " .. + "Call `read` again with `offset = %d` to continue.]\n", + limit, stopped_at_line, stopped_at_line + 1 + ) + end + return body + end, +} 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, +} diff --git a/agent/tools/write.lua b/agent/tools/write.lua new file mode 100644 index 0000000..70aa5c8 --- /dev/null +++ b/agent/tools/write.lua @@ -0,0 +1,81 @@ +-- Write text to a file, creating parent directories as needed. Pi-style +-- terse description because every byte of every tool description rides +-- in the system prompt on every turn. + +local uv = require("luv") + +-- mkdir -p equivalent: walk the path components and create each. +-- Ignores EEXIST 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 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 + +-- 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 + +return { + name = "write", + description = "Save content to a path. Overwrites existing files; missing parent directories are created automatically.", + schema = { + type = "object", + properties = { + path = { type = "string", description = "Path to the file (relative or absolute)." }, + content = { type = "string", description = "Exact bytes to write. No newline is added." }, + }, + required = { "path", "content" }, + }, + handler = function(input) + local path = input.path + local content = input.content + + if type(path) ~= "string" or path == "" then + return "Error: `path` must be a non-empty string." + end + if type(content) ~= "string" then + return "Error: `content` must be a string." + end + + local parent = dirname(path) + if parent then + local ok, err = mkdir_p(parent) + if not ok then + return "Error: could not create parent directory " .. parent .. ": " .. tostring(err) + end + end + + local f, open_err = io.open(path, "wb") + if not f then + return "Error: " .. (open_err or ("could not open " .. path .. " for writing")) + end + + local ok, write_err = f:write(content) + if not ok then + f:close() + return "Error: write failed: " .. tostring(write_err) + end + + local close_ok, close_err = f:close() + if not close_ok then + return "Error: close failed: " .. tostring(close_err) + end + + return string.format("Wrote %d bytes to %s.", #content, path) + end, +} diff --git a/build.zig b/build.zig index bcfe1be..659638a 100644 --- a/build.zig +++ b/build.zig @@ -316,9 +316,18 @@ fn generateLuarocksEmbed( /// `$PANTO_HOME/agent/` on first run, where the runtime's extension /// loader finds it as the "system" layer (below user and project). /// -/// Mirrors `generateLuarocksEmbed`'s addCopyDirectory trick so the -/// emitted `@embedFile("agent_src/")` references resolve relative -/// to the generated zig file. +/// Unlike `generateLuarocksEmbed`, the agent tree lives in-repo and +/// its contents change as we add/edit tools. `addDirectoryArg` on a +/// local source dir does NOT hash directory contents into the cache +/// key (only the path string), so editing a file under `agent/` +/// wouldn't invalidate this step's cache. To make cache invalidation +/// correct, we enumerate the tree at *build-script execution time* +/// and call `addFileInput` for every file we find. Each file then +/// participates in the cache key. +/// +/// `addCopyDirectory` (below) still does the actual staging of the +/// tree alongside the generated zig file so `@embedFile("agent_src/...")` +/// references resolve. fn generateAgentEmbed(b: *std.Build) std.Build.LazyPath { const tool = b.addExecutable(.{ .name = "gen-agent-embed", @@ -336,12 +345,48 @@ fn generateAgentEmbed(b: *std.Build) std.Build.LazyPath { run.addDirectoryArg(b.path("agent")); const gen_file = run.addOutputFileArg("embedded_agent.zig"); + // Enumerate every file under `agent/` and register it as an + // explicit cache input. This is what makes `zig build` notice + // edits to e.g. `agent/tools/read.lua` without a manual + // `rm -rf .zig-cache`. + addAgentTreeFileInputs(b, run, "agent") catch |err| { + std.debug.panic("failed to enumerate agent/ tree: {t}", .{err}); + }; + const wf = b.addWriteFiles(); const out_zig = wf.addCopyFile(gen_file, "embedded_agent.zig"); _ = wf.addCopyDirectory(b.path("agent"), "agent_src", .{}); return out_zig; } +/// Recursively walk `subpath` under the build root and call +/// `addFileInput` on every regular file. Used to give the agent-embed +/// codegen accurate cache invalidation. Dotfiles (any path segment +/// starting with `.`) are skipped to match the codegen's filter. +fn addAgentTreeFileInputs( + b: *std.Build, + run: *std.Build.Step.Run, + subpath: []const u8, +) !void { + const io = b.graph.io; + var dir = b.build_root.handle.openDir(io, subpath, .{ .iterate = true }) catch |err| switch (err) { + error.FileNotFound => return, + else => return err, + }; + defer dir.close(io); + + var walker = try dir.walk(b.allocator); + defer walker.deinit(); + + while (try walker.next(io)) |entry| { + if (entry.kind != .file) continue; + if (entry.basename.len == 0 or entry.basename[0] == '.') continue; + if (std.mem.indexOf(u8, entry.path, "/.") != null) continue; + const rel = b.pathJoin(&.{ subpath, entry.path }); + run.addFileInput(b.path(rel)); + } +} + /// Codegen step: emit a Zig module that exposes every Lua public header /// from the Lua source tarball as embedded bytes. The bootstrap stages /// these under `$PANTO_HOME/rocks/lua-X.Y.Z/include/` so luarocks can diff --git a/src/lua_runtime.zig b/src/lua_runtime.zig index 71e4ae7..5efadaa 100644 --- a/src/lua_runtime.zig +++ b/src/lua_runtime.zig @@ -67,6 +67,11 @@ pub const LuaRuntime = struct { /// tick libuv between coroutine resumes. `0` until /// `installScheduler` runs. uv_run_ref: c_int = 0, + /// Registry ref to `require("luv").loop_alive`, used by the + /// scheduler to detect handler coroutines that yielded without + /// arming any libuv work (a deadlock we surface rather than hang + /// on). `0` until `installScheduler` runs. + uv_loop_alive_ref: c_int = 0, /// Pointer to the in-flight batch, valid only for the duration of /// one `invoke_batch` call. The `panto._record_result` C function @@ -129,6 +134,9 @@ pub const LuaRuntime = struct { if (self.uv_run_ref != 0) { c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, self.uv_run_ref); } + if (self.uv_loop_alive_ref != 0) { + c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, self.uv_loop_alive_ref); + } c.lua_close(self.L); @@ -467,7 +475,7 @@ fn runBatch( } while (pending > 0) { - const active = try driveUvOnce(self); + try driveUvOnce(self); // Reap any coroutines that terminated during the tick. var reaped: usize = 0; for (thread_refs, 0..) |tref, i| { @@ -504,10 +512,17 @@ fn runBatch( } continue; } - if (active == 0) { + if (!try loopAlive(self)) { // No libuv handles are pending, but we still have alive // coroutines. They yielded without arranging to be woken. // Mark them as failed and break. + // + // Note: we ask `uv.loop_alive` rather than relying on the + // return value of `uv.run("once")`, which is a boolean + // signalling whether `uv.stop()` was called — not a count + // of active handles. Conflating the two used to break + // multi-tick tools (e.g. `bash`) on their second tick. + for (thread_refs, 0..) |tref, i| { if (tref == 0) continue; slots[i] = .{ @@ -527,9 +542,25 @@ fn runBatch( } // Translate slots into the libpanto-shaped results. + // + // Important: a handler that raised (`ok == false`) or otherwise + // misbehaved (`!recorded`) is surfaced to the model as an `.ok` + // result whose body is the formatted error message. We do *not* + // return `.err` here, because libpanto treats any per-call `.err` + // as an unrecoverable failure that aborts the entire turn (see + // `agent.dispatchToolCalls`). Aborting the turn over a Lua-level + // bug — in either a builtin tool or a user extension — is far + // more disruptive than handing the model a readable error and + // letting it correct course on the next turn. for (slots, 0..) |slot, i| { if (!slot.recorded) { - results[i] = .{ .err = RuntimeError.BadHandlerReturn }; + results[i] = .{ + .ok = try formatToolError( + allocator, + calls[i].tool_name, + "handler terminated without recording a result", + ), + }; continue; } if (slot.ok) { @@ -538,9 +569,6 @@ fn runBatch( if (slot.err_msg) |m| allocator.free(m); } else { if (slot.value) |v| allocator.free(v); - // The error message was logged for the user; we still - // surface a typed error to libpanto so it can route the - // failure to the agent's error path. std.log.warn( "panto-lua: tool '{s}' failed: {s}", .{ @@ -548,12 +576,33 @@ fn runBatch( slot.err_msg orelse "(no message)", }, ); + results[i] = .{ + .ok = try formatToolError( + allocator, + calls[i].tool_name, + slot.err_msg orelse "(no message)", + ), + }; if (slot.err_msg) |m| allocator.free(m); - results[i] = .{ .err = RuntimeError.LuaHandlerCrashed }; } } } +/// Format a tool-level failure as a textual result the model can read. +/// The prefix mirrors what the user sees in `panto-lua` log lines so +/// the model and the developer are looking at the same string. +fn formatToolError( + allocator: Allocator, + tool_name: []const u8, + message: []const u8, +) ![]u8 { + return std.fmt.allocPrint( + allocator, + "panto-lua: tool '{s}' failed: {s}", + .{ tool_name, message }, + ); +} + /// Start one coroutine: create a thread under the runtime's lua_State, /// push the wrapper closure + (idx, handler, input), `lua_resume` once. /// @@ -592,9 +641,10 @@ fn startCoroutine( }; } -/// Call `uv.run("once")`. Returns the number of active handles luv -/// reports remain pending (0 means the loop is drained). -fn driveUvOnce(self: *LuaRuntime) !c_int { +/// Call `uv.run("once")`. The return value (a boolean meaning "was +/// `uv.stop()` called with handles still alive?") is not useful to us +/// — to decide whether to keep ticking we call `loopAlive` separately. +fn driveUvOnce(self: *LuaRuntime) !void { const L = self.L; _ = c.lua_rawgeti(L, lua_bridge.LUA_REGISTRYINDEX, @intCast(self.uv_run_ref)); _ = c.lua_pushlstring(L, "once", 4); @@ -603,9 +653,25 @@ fn driveUvOnce(self: *LuaRuntime) !c_int { c.lua_settop(L, c.lua_gettop(L) - 1); return error.UvRunFailed; } - const n = c.lua_tointegerx(L, -1, null); + // Discard the boolean return value. + c.lua_settop(L, c.lua_gettop(L) - 1); +} + +/// Call `uv.loop_alive()`. Returns true iff libuv has any referenced +/// active handles, requests, or closing handles. We use this — not +/// the return value of `uv.run` — to detect coroutines that yielded +/// without arranging to be woken. +fn loopAlive(self: *LuaRuntime) !bool { + const L = self.L; + _ = c.lua_rawgeti(L, lua_bridge.LUA_REGISTRYINDEX, @intCast(self.uv_loop_alive_ref)); + if (c.lua_pcallk(L, 0, 1, 0, 0, null) != 0) { + logTopAsError(L, "panto-lua: uv.loop_alive failed"); + c.lua_settop(L, c.lua_gettop(L) - 1); + return error.UvRunFailed; + } + const alive = c.lua_toboolean(L, -1) != 0; c.lua_settop(L, c.lua_gettop(L) - 1); - return @intCast(n); + return alive; } /// Pre-scheduler fallback (used in unit tests and during early @@ -616,19 +682,47 @@ fn runLegacySync( allocator: Allocator, ) panto.ToolCallResult { const handler_ref = self.handlers.get(call.tool_name) orelse { - return .{ .err = RuntimeError.LuaHandlerNotFound }; + const bytes = formatToolError( + allocator, + call.tool_name, + "unknown tool name", + ) catch return .{ .err = error.OutOfMemory }; + return .{ .ok = bytes }; }; - const out_bytes = invokeCoroutineSync(self.L, handler_ref, call.input, allocator) catch |e| { - return .{ .err = e }; + var err_msg: ?[]u8 = null; + defer if (err_msg) |m| allocator.free(m); + const out_bytes = invokeCoroutineSync( + self.L, + handler_ref, + call.input, + allocator, + &err_msg, + ) catch |e| { + // Surface the failure to the model as a textual `.ok` result + // rather than aborting the turn. Prefer the Lua-side error + // message (captured into `err_msg` by invokeCoroutineSync + // when available); fall back to the typed error name. + const message: []const u8 = err_msg orelse @errorName(e); + const bytes = formatToolError( + allocator, + call.tool_name, + message, + ) catch return .{ .err = error.OutOfMemory }; + return .{ .ok = bytes }; }; return .{ .ok = out_bytes }; } +/// Run a handler synchronously, with no event loop. On Lua-level +/// failure the error message string is duped into `*err_msg_out` +/// (caller owns) before returning the typed error. `err_msg_out` is +/// only written on the error path; on success it is left untouched. fn invokeCoroutineSync( L: *c.lua_State, handler_ref: c_int, input: []const u8, allocator: Allocator, + err_msg_out: *?[]u8, ) ![]u8 { const co = c.lua_newthread(L) orelse return RuntimeError.LuaInitFailed; defer c.lua_settop(L, c.lua_gettop(L) - 1); @@ -660,6 +754,15 @@ fn invokeCoroutineSync( return RuntimeError.LuaHandlerYielded; }, else => { + // Capture the Lua error message into the caller's slot + // *before* logging+returning, so the legacy sync path can + // surface it to the model rather than just the typed + // error name. + var msg_len: usize = 0; + const msg_ptr = c.lua_tolstring(co, -1, &msg_len); + if (msg_ptr != null) { + err_msg_out.* = allocator.dupe(u8, msg_ptr[0..msg_len]) catch null; + } logTopAsError(co, "lua: handler crashed"); return RuntimeError.LuaHandlerCrashed; }, @@ -760,27 +863,32 @@ fn installWrapperClosure(self: *LuaRuntime) !void { self.wrapper_ref = c.luaL_ref(L, lua_bridge.LUA_REGISTRYINDEX); } -/// Cache `require("luv").run` in the registry so the scheduler can -/// invoke it cheaply per tick. +/// Cache `require("luv").run` and `require("luv").loop_alive` in the +/// registry so the scheduler can invoke them cheaply per tick. fn cacheUvRun(self: *LuaRuntime) !void { const L = self.L; const snippet = - \\return require("luv").run + \\local uv = require("luv") + \\return uv.run, uv.loop_alive ; if (c.luaL_loadstring(L, snippet) != 0) { logTopAsError(L, "panto-lua: failed to compile luv lookup"); c.lua_settop(L, c.lua_gettop(L) - 1); return RuntimeError.LuaInitFailed; } - if (c.lua_pcallk(L, 0, 1, 0, 0, null) != 0) { + if (c.lua_pcallk(L, 0, 2, 0, 0, null) != 0) { logTopAsError(L, "panto-lua: require('luv') failed (was the bootstrap successful?)"); c.lua_settop(L, c.lua_gettop(L) - 1); return RuntimeError.LuaInitFailed; } - if (c.lua_type(L, -1) != lua_bridge.T_FUNCTION) { - c.lua_settop(L, c.lua_gettop(L) - 1); + // Stack: [..., uv.run, uv.loop_alive]. luaL_ref pops the top. + if (c.lua_type(L, -1) != lua_bridge.T_FUNCTION or + c.lua_type(L, -2) != lua_bridge.T_FUNCTION) + { + c.lua_settop(L, c.lua_gettop(L) - 2); return RuntimeError.LuaInitFailed; } + self.uv_loop_alive_ref = c.luaL_ref(L, lua_bridge.LUA_REGISTRYINDEX); self.uv_run_ref = c.luaL_ref(L, lua_bridge.LUA_REGISTRYINDEX); } @@ -1003,7 +1111,17 @@ test "handler crash: per-call error surfaces, sibling calls succeed" { }; try testing.expectEqualStrings("fine", results[0].ok); - try testing.expectEqual(@as(anyerror, RuntimeError.LuaHandlerCrashed), results[1].err); + // A handler crash is surfaced as an *ok* result whose payload is + // the formatted error message — not as `.err` — because libpanto + // would otherwise abort the entire turn on per-call `.err`. The + // payload starts with the well-known `panto-lua: tool '...' failed:` + // prefix and includes the Lua-side error message. + try testing.expect(std.mem.startsWith( + u8, + results[1].ok, + "panto-lua: tool 'boom' failed:", + )); + try testing.expect(std.mem.indexOf(u8, results[1].ok, "kaboom") != null); try testing.expectEqualStrings("fine", results[2].ok); } @@ -1076,8 +1194,21 @@ test "yielding handler with no event loop surfaces LuaHandlerYielded" { const calls = [_]panto.ToolCall{.{ .tool_name = "sleeper", .input = "{}" }}; var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }}; try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); + defer for (results) |r| switch (r) { + .ok => |b| testing.allocator.free(b), + .err => {}, + }; - try testing.expectEqual(@as(anyerror, RuntimeError.LuaHandlerYielded), results[0].err); + // Same policy as the crash test: the failure is surfaced as `.ok` + // text so libpanto doesn't abort the turn. The error type name + // (`LuaHandlerYielded`) is included via `@errorName` in the legacy + // sync path's formatToolError call. + try testing.expect(std.mem.startsWith( + u8, + results[0].ok, + "panto-lua: tool 'sleeper' failed:", + )); + try testing.expect(std.mem.indexOf(u8, results[0].ok, "LuaHandlerYielded") != null); } // Integration test: requires a `$PANTO_HOME` with luv already diff --git a/src/main.zig b/src/main.zig index f95812d..bdb5390 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,6 +1,5 @@ const std = @import("std"); const panto = @import("panto"); -const ping_tool = @import("ping_tool.zig"); const lua_bridge = @import("lua_bridge.zig"); const lua_runtime = @import("lua_runtime.zig"); const extension_loader = @import("extension_loader.zig"); @@ -370,10 +369,6 @@ pub fn main(init: std.process.Init) !void { var agent = panto.agent.Agent.init(alloc, io, prov); defer agent.deinit(); - // smoke test: register a trivial built-in tool so we can exercise - // the tool-call loop against a real LLM. - try agent.registerTool(ping_tool.tool()); - // Spin up the long-lived Lua runtime. All Lua extensions load into // one `lua_State`; module-global state survives across calls. The // runtime registers with the agent as a single `ToolSource` named diff --git a/src/ping_tool.zig b/src/ping_tool.zig deleted file mode 100644 index d18bc02..0000000 --- a/src/ping_tool.zig +++ /dev/null @@ -1,59 +0,0 @@ -//! A trivial built-in "ping" tool used to exercise the tool-call loop -//! end-to-end against a real LLM. It accepts a `host` string and always -//! returns the literal string "PONG", regardless of what the host is. -//! -//! In phase 5 this kind of built-in will be replaced by a proper extension; -//! for now it lives in the CLI so we can verify the libpanto tool plumbing -//! without writing the Lua bridge first. - -const std = @import("std"); -const panto = @import("panto"); - -const NAME = "ping"; -const DESCRIPTION = - \\Test whether a server is reachable by hostname. Always responds with - \\"PONG" when the server is up. Use this when the user asks you to check - \\if a host is online or reachable. -; -const SCHEMA_JSON = - \\{ - \\ "type": "object", - \\ "properties": { - \\ "host": { - \\ "type": "string", - \\ "description": "The hostname or address to ping." - \\ } - \\ }, - \\ "required": ["host"] - \\} -; - -/// Returns a `Tool` value ready to register with `Agent.registerTool`. The -/// tool holds no per-instance state: name, description, and schema are -/// `comptime` string literals, and the vtable's deinit is a no-op. We pass -/// a sentinel pointer as ctx since the contract requires *anyopaque but -/// nothing dereferences it. -pub fn tool() panto.Tool { - return .{ - .decl = .{ - .name = NAME, - .description = DESCRIPTION, - .schema_json = SCHEMA_JSON, - }, - .ctx = &ctx_sentinel, - .vtable = &vtable, - }; -} - -var ctx_sentinel: u8 = 0; - -const vtable: panto.Tool.VTable = .{ - .invoke = invoke, - .deinit = deinitNoop, -}; - -fn invoke(_: *anyopaque, _: []const u8, allocator: std.mem.Allocator) anyerror![]u8 { - return try allocator.dupe(u8, "PONG"); -} - -fn deinitNoop(_: *anyopaque, _: std.mem.Allocator) void {} -- cgit v1.3