summaryrefslogtreecommitdiff
path: root/agent/tools
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-15 22:11:53 -0600
committert <t@tjp.lol>2026-06-16 11:17:14 -0600
commitf8c6d45755acb5abf9ac68931268b7945da0fae0 (patch)
treea88ed7edd4aa3e5acced9ff99ac61b7f36b267a6 /agent/tools
parent8206642d3a1736aaf928a5b9a732e747f5294228 (diff)
display fixes: markdown rendering, tool-specific components
Diffstat (limited to 'agent/tools')
-rw-r--r--agent/tools/edit.lua297
-rw-r--r--agent/tools/read.lua351
-rw-r--r--agent/tools/shell.lua40
-rw-r--r--agent/tools/write.lua101
4 files changed, 412 insertions, 377 deletions
diff --git a/agent/tools/edit.lua b/agent/tools/edit.lua
index ec96aa6..b32516a 100644
--- a/agent/tools/edit.lua
+++ b/agent/tools/edit.lua
@@ -10,8 +10,39 @@
-- All matches are evaluated against the original file, not
-- progressively against the result of earlier entries.
+local panto = require("panto")
local uv = require("luv")
+local tool = {
+ name = "std.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" },
+ },
+}
+
+panto.ext.on("tool", function(e)
+ if e.tool_name ~= tool.name then return end
+ e:set_component(e:get_component())
+end)
+
-- ---------------------------------------------------------------------------
-- Coroutine-synchronous libuv wrappers. panto runs each tool handler in
-- its own coroutine and drives a single `uv.run()` to completion; a
@@ -86,170 +117,148 @@ local function write_all(path, content)
return true, nil
end
-return {
- name = "std.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
+tool.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
+ 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 original, read_err = read_all(path)
- if not original then
- return "Error: " .. (read_err or ("could not read " .. path))
- end
+ -- Load the file.
+ local original, read_err = read_all(path)
+ if not original then
+ return "Error: " .. (read_err or ("could not 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
+ -- 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
+ 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` and `new` must both be strings", i
+ "edit #%d: `old` not found in file. Snippet: %q",
+ i, snippet
)
ranges[i] = nil
- elseif e.old == "" then
+ elseif count > 1 then
problems[#problems + 1] = string.format(
- "edit #%d: `old` is the empty string (would be ambiguous)", i
+ "edit #%d: `old` matches %d times (need exactly 1). " ..
+ "Widen with surrounding context to disambiguate.",
+ i, count
)
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
+ 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
+ -- 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
+ 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)
+ -- 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)
+ 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
- local new_content = table.concat(out)
+ 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 ok, write_err = write_all(path, new_content)
- if not ok then
- return "Error: file read OK but write failed: " ..
- tostring(write_err)
- end
+ -- Write back.
+ local ok, write_err = write_all(path, new_content)
+ if not ok then
+ return "Error: file read OK but write failed: " ..
+ tostring(write_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,
-}
+ 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
+
+return tool
diff --git a/agent/tools/read.lua b/agent/tools/read.lua
index 0328efc..f691f44 100644
--- a/agent/tools/read.lua
+++ b/agent/tools/read.lua
@@ -1,3 +1,25 @@
+local panto = require("panto")
+local uv = require("luv")
+
+local tool = {
+ name = "std.read",
+ description = "Read a file from disk. Output is truncated to the first 50KB or 2000 lines, whichever is hit first; directories return a clearly tagged non-recursive listing. Use offset/limit to slice. Works with images and PDFs.",
+ schema = {
+ type = "object",
+ properties = {
+ path = { type = "string", description = "Path to the file or directory (relative or absolute)." },
+ offset = { type = "integer", description = "1-based line to start at.", minimum = 1 },
+ limit = { type = "integer", description = "Maximum lines to return.", minimum = 1 },
+ },
+ required = { "path" },
+ },
+}
+
+panto.ext.on("tool", function(e)
+ if e.tool_name ~= tool.name then return end
+ e:set_component(e:get_component())
+end)
+
-- Read a file from disk and return its text verbatim.
--
-- Output cap: 50 KB and 2000 lines, whichever is hit first. Large
@@ -13,8 +35,6 @@
-- If `path` names a directory, return a clearly tagged non-recursive
-- listing of its immediate children instead of an error.
-local uv = require("luv")
-
-- Minimal magic-byte sniff: binary attachment (image/PDF) vs. text.
-- We only decide *whether* the bytes are an attachment; libpanto does the
-- precise type detection, resizing, and encoding from the raw bytes. Keep
@@ -216,197 +236,186 @@ local function render_directory_listing(path, offset, limit)
return body
end
-return {
- name = "std.read",
- description = "Read a file from disk. Output is truncated to the first 50KB or 2000 lines, whichever is hit first; directories return a clearly tagged non-recursive listing. Use offset/limit to slice. Works with images and PDFs.",
- schema = {
- type = "object",
- properties = {
- path = { type = "string", description = "Path to the file or directory (relative or absolute)." },
- offset = { type = "integer", description = "1-based line to start at.", minimum = 1 },
- limit = { type = "integer", description = "Maximum 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"
+tool.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
+ 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 stat_err, stat = fs_stat(path)
- if not stat then
- return "Error: " .. (stat_err or ("could not stat " .. path))
- end
- if stat.type == "directory" then
- return render_directory_listing(path, offset, limit)
- end
+ local stat_err, stat = fs_stat(path)
+ if not stat then
+ return "Error: " .. (stat_err or ("could not stat " .. path))
+ end
+ if stat.type == "directory" then
+ return render_directory_listing(path, offset, limit)
+ end
- local open_err, fd = fs_open(path, "r", 0)
- if not fd then
- return "Error: " .. (open_err or ("could not open " .. path))
- end
+ local open_err, fd = fs_open(path, "r", 0)
+ if not fd then
+ return "Error: " .. (open_err or ("could not open " .. path))
+ end
- -- Binary attachment path. We do the *minimal* magic-byte sniff
- -- here — only enough to decide "binary attachment vs. text". If
- -- it's an attachment, we return the raw file bytes; libpanto does
- -- the real work (precise type detection, resizing, encoding).
- do
- local herr, header = fs_read(fd, 16, 0)
- if not herr and header and #header > 0 and is_attachment(header) then
- local whole, rerr = (function()
- local chunks = {}
- local off = 0
- while true do
- local e, d = fs_read(fd, READ_CHUNK_SIZE, off)
- if e then return nil, e end
- if d == nil or #d == 0 then break end
- chunks[#chunks + 1] = d
- off = off + #d
- end
- return table.concat(chunks)
- end)()
- fs_close(fd)
- if not whole then
- return "Error: read failed: " .. tostring(rerr)
+ -- Binary attachment path. We do the *minimal* magic-byte sniff
+ -- here — only enough to decide "binary attachment vs. text". If
+ -- it's an attachment, we return the raw file bytes; libpanto does
+ -- the real work (precise type detection, resizing, encoding).
+ do
+ local herr, header = fs_read(fd, 16, 0)
+ if not herr and header and #header > 0 and is_attachment(header) then
+ local whole, rerr = (function()
+ local chunks = {}
+ local off = 0
+ while true do
+ local e, d = fs_read(fd, READ_CHUNK_SIZE, off)
+ if e then return nil, e end
+ if d == nil or #d == 0 then break end
+ chunks[#chunks + 1] = d
+ off = off + #d
end
- return {
- text = string.format("[read %s as binary attachment]", path),
- attachments = { { data = whole } },
- }
+ return table.concat(chunks)
+ end)()
+ fs_close(fd)
+ if not whole then
+ return "Error: read failed: " .. tostring(rerr)
end
+ return {
+ text = string.format("[read %s as binary attachment]", path),
+ attachments = { { data = whole } },
+ }
end
+ end
- local parts = {}
- local emitted_lines = 0
- local emitted_bytes = 0
- local lineno = 0
- local truncated_reason = nil
- local stopped_at_line = nil
- local effective_line_cap = MAX_LINES
- if limit and limit < effective_line_cap then
- effective_line_cap = limit
- end
+ local parts = {}
+ local emitted_lines = 0
+ local emitted_bytes = 0
+ local lineno = 0
+ local truncated_reason = nil
+ local stopped_at_line = nil
+ local effective_line_cap = MAX_LINES
+ if limit and limit < effective_line_cap then
+ effective_line_cap = limit
+ end
- local file_offset = 0
- local done = false
- local read_err = nil
- local saw_any_bytes = false
- local saw_trailing_newline = false
+ local file_offset = 0
+ local done = false
+ local read_err = nil
+ local saw_any_bytes = false
+ local saw_trailing_newline = false
- local carry = ""
- local function process_line(line)
- lineno = lineno + 1
- if lineno < offset then
- return
- end
+ local carry = ""
+ local function process_line(line)
+ lineno = lineno + 1
+ if lineno < offset then
+ return
+ end
- local rendered = line .. "\n"
- if emitted_bytes + #rendered > MAX_BYTES then
- truncated_reason = "bytes"
- stopped_at_line = lineno
- done = true
- return
- end
- parts[#parts + 1] = rendered
- emitted_bytes = emitted_bytes + #rendered
- emitted_lines = emitted_lines + 1
- if emitted_lines >= effective_line_cap then
- stopped_at_line = lineno
- done = true
- end
+ local rendered = line .. "\n"
+ if emitted_bytes + #rendered > MAX_BYTES then
+ truncated_reason = "bytes"
+ stopped_at_line = lineno
+ done = true
+ return
end
+ parts[#parts + 1] = rendered
+ emitted_bytes = emitted_bytes + #rendered
+ emitted_lines = emitted_lines + 1
+ if emitted_lines >= effective_line_cap then
+ stopped_at_line = lineno
+ done = true
+ end
+ end
- while not done do
- local err, data = fs_read(fd, READ_CHUNK_SIZE, file_offset)
- if err then
- read_err = err
- break
- end
- -- luv's `fs_read` signals EOF by returning the empty
- -- string, NOT nil (nil only accompanies an error). Treat
- -- both nil and "" as EOF; otherwise the loop re-reads at
- -- the same offset forever, pinning a core at 100%.
- if data == nil or #data == 0 then
- if #carry > 0 then
- process_line(carry)
- carry = ""
- end
- break
+ while not done do
+ local err, data = fs_read(fd, READ_CHUNK_SIZE, file_offset)
+ if err then
+ read_err = err
+ break
+ end
+ -- luv's `fs_read` signals EOF by returning the empty
+ -- string, NOT nil (nil only accompanies an error). Treat
+ -- both nil and "" as EOF; otherwise the loop re-reads at
+ -- the same offset forever, pinning a core at 100%.
+ if data == nil or #data == 0 then
+ if #carry > 0 then
+ process_line(carry)
+ carry = ""
end
+ break
+ end
- saw_any_bytes = true
- file_offset = file_offset + #data
- saw_trailing_newline = data:sub(-1) == "\n"
- local chunk = carry .. data
- local start = 1
+ saw_any_bytes = true
+ file_offset = file_offset + #data
+ saw_trailing_newline = data:sub(-1) == "\n"
+ local chunk = carry .. data
+ local start = 1
- while not done do
- local nl = chunk:find("\n", start, true)
- if not nl then break end
- local line = chunk:sub(start, nl - 1)
- if line:sub(-1) == "\r" then
- line = line:sub(1, -2)
- end
- process_line(line)
- start = nl + 1
+ while not done do
+ local nl = chunk:find("\n", start, true)
+ if not nl then break end
+ local line = chunk:sub(start, nl - 1)
+ if line:sub(-1) == "\r" then
+ line = line:sub(1, -2)
end
+ process_line(line)
+ start = nl + 1
+ end
- carry = chunk:sub(start)
- if done and truncated_reason == nil and emitted_lines >= effective_line_cap then
- truncated_reason = (limit and limit <= MAX_LINES) and "limit" or "lines"
- end
+ carry = chunk:sub(start)
+ if done and truncated_reason == nil and emitted_lines >= effective_line_cap then
+ truncated_reason = (limit and limit <= MAX_LINES) and "limit" or "lines"
end
+ end
- fs_close(fd)
+ fs_close(fd)
- if read_err then
- return "Error: read failed: " .. tostring(read_err)
- end
+ if read_err then
+ return "Error: read failed: " .. tostring(read_err)
+ end
- if #parts == 0 then
- if not saw_any_bytes 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
- if lineno == 0 and saw_any_bytes and not saw_trailing_newline then
- return "(empty file)\n"
- end
- return "(no lines in requested range)\n"
+ if #parts == 0 then
+ if not saw_any_bytes then
+ return "(empty file)\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
+ if offset > lineno then
+ return string.format(
+ "Error: offset (%d) is past end of file (%d lines).",
+ offset, lineno
)
end
- return body
- end,
-}
+ if lineno == 0 and saw_any_bytes and not saw_trailing_newline then
+ return "(empty file)\n"
+ 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
+
+return tool
diff --git a/agent/tools/shell.lua b/agent/tools/shell.lua
index f9be58b..6c0a37c 100644
--- a/agent/tools/shell.lua
+++ b/agent/tools/shell.lua
@@ -26,12 +26,32 @@
-- a string-returning tool result is shaped). Most shell commands the
-- agent runs interleave the two anyway.
+local panto = require("panto")
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
+local tool = {
+ name = "std.shell",
+ description = string.format("Execute a shell command via `/bin/sh -c`. Returns merged stdout+stderr after the command exits, prefixed with an exit-status header. Output is truncated to the last %dKB; on truncation the full transcript is saved to `$PANTO_HOME/shell-output/` and its path is included for follow-up `read` calls. Default timeout 5 minutes.", MAX_BYTES / 1024),
+ 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" },
+ },
+}
+
+panto.ext.on("tool", function(e)
+ if e.tool_name ~= tool.name then return end
+ e:set_component(e:get_component())
+end)
+
-- ---------------------------------------------------------------------------
-- Coroutine-synchronous coordination.
--
@@ -128,20 +148,7 @@ end
-- ---------------------------------------------------------------------------
-- Tool body
-- ---------------------------------------------------------------------------
-
-return {
- name = "std.shell",
- description = string.format("Execute a shell command via `/bin/sh -c`. Returns merged stdout+stderr after the command exits, prefixed with an exit-status header. Output is truncated to the last %dKB; on truncation the full transcript is saved to `$PANTO_HOME/shell-output/` and its path is included for follow-up `read` calls. Default timeout 5 minutes.", MAX_BYTES / 1024),
- 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)
+tool.handler = function(input)
local command = input.command
if type(command) ~= "string" or command == "" then
return "Error: `command` must be a non-empty string."
@@ -427,5 +434,6 @@ return {
end
end
return table.concat(parts, "\n")
- end,
-}
+ end
+
+return tool
diff --git a/agent/tools/write.lua b/agent/tools/write.lua
index 42cb85a..144a305 100644
--- a/agent/tools/write.lua
+++ b/agent/tools/write.lua
@@ -2,8 +2,27 @@
-- description terse because every byte of every tool description rides
-- in the system prompt on every turn.
+local panto = require("panto")
local uv = require("luv")
+local tool = {
+ name = "std.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" },
+ },
+}
+
+panto.ext.on("tool", function(e)
+ if e.tool_name ~= tool.name then return end
+ e:set_component(e:get_component())
+end)
+
-- ---------------------------------------------------------------------------
-- Coroutine-synchronous libuv wrappers. panto runs each tool handler in
-- its own coroutine and drives a single `uv.run()` to completion; a
@@ -68,57 +87,47 @@ local function dirname(path)
return path:sub(1, slash - 1)
end
-return {
- name = "std.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
+tool.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
+ 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
+ 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 open_err, fd = fs_open(path, "w", tonumber("644", 8))
- if not fd then
- return "Error: " .. (open_err or ("could not open " .. path .. " for writing"))
- end
+ local open_err, fd = fs_open(path, "w", tonumber("644", 8))
+ if not fd then
+ return "Error: " .. (open_err or ("could not open " .. path .. " for writing"))
+ end
- -- A single `fs_write` may short-write; loop until all bytes land.
- local offset = 0
- while offset < #content do
- local werr, n = fs_write(fd, content:sub(offset + 1), offset)
- if not n then
- fs_close(fd)
- return "Error: write failed: " .. tostring(werr)
- end
- offset = offset + n
+ -- A single `fs_write` may short-write; loop until all bytes land.
+ local offset = 0
+ while offset < #content do
+ local werr, n = fs_write(fd, content:sub(offset + 1), offset)
+ if not n then
+ fs_close(fd)
+ return "Error: write failed: " .. tostring(werr)
end
+ offset = offset + n
+ end
- local close_err = fs_close(fd)
- if close_err then
- return "Error: close failed: " .. tostring(close_err)
- end
+ local close_err = fs_close(fd)
+ if close_err then
+ return "Error: close failed: " .. tostring(close_err)
+ end
- return string.format("Wrote %d bytes to %s.", #content, path)
- end,
-}
+ return string.format("Wrote %d bytes to %s.", #content, path)
+end
+
+return tool