summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
l---------.panto1
-rw-r--r--agent/tools/edit.lua193
-rw-r--r--agent/tools/read.lua139
-rw-r--r--agent/tools/shell.lua397
-rw-r--r--agent/tools/write.lua81
-rw-r--r--build.zig51
-rw-r--r--src/lua_runtime.zig177
-rw-r--r--src/main.zig5
-rw-r--r--src/ping_tool.zig59
9 files changed, 1012 insertions, 91 deletions
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 <command>`, 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: <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
+-- ---------------------------------------------------------------------------
+
+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/<rel>")` 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 {}