From f8c6d45755acb5abf9ac68931268b7945da0fae0 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 15 Jun 2026 22:11:53 -0600 Subject: display fixes: markdown rendering, tool-specific components --- agent/tools/read.lua | 353 ++++++++++++++++++++++++++------------------------- 1 file changed, 181 insertions(+), 172 deletions(-) (limited to 'agent/tools/read.lua') 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 + 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 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 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 - 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 + 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 - 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 -- cgit v1.3