-- Read a file from disk and return its text verbatim. -- -- Output cap: 50 KB and 2000 lines, whichever is hit first. 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. When the cap is hit, a `[truncated: ...]` marker names the -- next `offset` for a follow-up call. -- -- 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 -- this in lockstep with `libpanto/src/image.zig`'s `detectCodec`. local function is_attachment(head) local b = { head:byte(1, 12) } -- PNG: 89 50 4E 47 0D 0A 1A 0A if b[1] == 0x89 and b[2] == 0x50 and b[3] == 0x4E and b[4] == 0x47 and b[5] == 0x0D and b[6] == 0x0A and b[7] == 0x1A and b[8] == 0x0A then return true end -- JPEG: FF D8 FF if b[1] == 0xFF and b[2] == 0xD8 and b[3] == 0xFF then return true end -- GIF: "GIF87a" / "GIF89a" if head:sub(1, 6) == "GIF87a" or head:sub(1, 6) == "GIF89a" then return true end -- BMP: "BM" if b[1] == 0x42 and b[2] == 0x4D then return true end -- WEBP: "RIFF"????"WEBP" if head:sub(1, 4) == "RIFF" and head:sub(9, 12) == "WEBP" then return true end -- PDF: "%PDF-" if head:sub(1, 5) == "%PDF-" then return true end return false end local MAX_BYTES = 50 * 1024 -- 50 KB local MAX_LINES = 2000 local READ_CHUNK_SIZE = MAX_BYTES -- --------------------------------------------------------------------------- -- Coroutine-synchronous libuv wrappers. -- -- panto runs every tool handler inside its own Lua coroutine and drives -- a single `uv.run()` to completion. The contract for an async handler -- is: the ONLY way it may block is to yield on a pending libuv request -- whose callback resumes it exactly once. These helpers encapsulate -- that pattern — each fires one async libuv op, yields, and returns the -- callback's results once resumed. They look synchronous to the caller -- but cooperate with sibling tool calls sharing the event loop. -- -- INVARIANTS each wrapper upholds (and every async tool must too): -- * exactly one libuv op armed per yield; -- * the callback resumes the coroutine exactly once; -- * `coroutine.resume` errors are re-raised so a crash in the resumed -- handler isn't silently swallowed inside the uv callback. -- --------------------------------------------------------------------------- -- Run a single callback-style libuv fs operation and block the -- coroutine until its callback fires. `arm` receives a `resolve` -- function and must start exactly one libuv op that calls it. local function await(arm) local co = assert(coroutine.running(), "read: await must run inside a tool handler coroutine") local res, fired = nil, false arm(function(...) assert(not fired, "read: libuv callback fired twice") fired = true res = table.pack(...) local ok, err = coroutine.resume(co) if not ok then error(err, 0) end end) coroutine.yield() return table.unpack(res, 1, res.n) end local function fs_stat(path) return await(function(resolve) uv.fs_stat(path, resolve) end) end local function fs_open(path, flags, mode) return await(function(resolve) uv.fs_open(path, flags, mode, resolve) end) end local function fs_read(fd, size, offset) return await(function(resolve) uv.fs_read(fd, size, offset, resolve) end) end local function fs_close(fd) return await(function(resolve) uv.fs_close(fd, resolve) end) end -- `uv.fs_opendir` takes the entries-per-readdir batch hint as its -- SECOND positional arg, before the callback. We pass it explicitly so -- each `fs_readdir` returns up to `n` entries per round-trip. local READDIR_BATCH = 256 local function fs_opendir(path) return await(function(resolve) uv.fs_opendir(path, resolve, READDIR_BATCH) end) end local function fs_readdir(dir) return await(function(resolve) uv.fs_readdir(dir, resolve) end) end local function fs_closedir(dir) return await(function(resolve) uv.fs_closedir(dir, resolve) end) end local function scandir_sorted(path) -- Use the streaming opendir/readdir/closedir API rather than -- `fs_scandir` + `fs_scandir_next`: the latter's iterator advances -- with synchronous `readdir(3)` syscalls, which would block the -- shared event loop on large directories. opendir/readdir route -- every batch through libuv's thread pool, keeping us cooperative. local err, dir = fs_opendir(path) if not dir then return nil, err end local entries = {} while true do local rerr, batch = fs_readdir(dir) if rerr then fs_closedir(dir) return nil, rerr end -- A nil/empty batch signals end of the directory stream. if batch == nil or #batch == 0 then break end for _, e in ipairs(batch) do -- async readdir yields { name = ..., type = ... }. entries[#entries + 1] = { name = e.name, typ = e.type } end end fs_closedir(dir) table.sort(entries, function(a, b) return a.name < b.name end) return entries end local function render_directory_listing(path, offset, limit) local entries, err = scandir_sorted(path) if not entries then return "Error: " .. (err or ("could not list directory " .. path)) end local total = #entries if total == 0 then return string.format("[directory listing for %s]\n(empty directory)\n", path) end if offset > total then return string.format( "Error: offset (%d) is past end of directory listing (%d entries).", offset, total ) end local effective_line_cap = MAX_LINES if limit and limit < effective_line_cap then effective_line_cap = limit end local parts = { string.format("[directory listing for %s]\n", path), } local emitted_lines = 0 local emitted_bytes = #parts[1] local truncated_reason = nil local stopped_at_entry = nil for idx = offset, total do local entry = entries[idx] local rendered = string.format("%s\t%s\n", entry.typ or "unknown", entry.name) if emitted_bytes + #rendered > MAX_BYTES then truncated_reason = "bytes" stopped_at_entry = idx break end parts[#parts + 1] = rendered emitted_bytes = emitted_bytes + #rendered emitted_lines = emitted_lines + 1 if emitted_lines >= effective_line_cap then if idx < total then truncated_reason = (limit and limit <= MAX_LINES) and "limit" or "lines" stopped_at_entry = idx end break end end local body = table.concat(parts) if truncated_reason == "bytes" then body = body .. string.format( "\n[truncated directory listing: hit %d-byte cap at entry %d. " .. "Call `read` again with `offset = %d` (and a smaller `limit` if needed) to continue.]\n", MAX_BYTES, stopped_at_entry, stopped_at_entry ) elseif truncated_reason == "lines" then body = body .. string.format( "\n[truncated directory listing: hit %d-line cap at entry %d. " .. "Call `read` again with `offset = %d` to continue.]\n", MAX_LINES, stopped_at_entry, stopped_at_entry + 1 ) elseif truncated_reason == "limit" then body = body .. string.format( "\n[truncated directory listing: hit caller's `limit = %d` at entry %d. " .. "Call `read` again with `offset = %d` to continue.]\n", limit, stopped_at_entry, stopped_at_entry + 1 ) end 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" 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 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) 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 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 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 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 end fs_close(fd) 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" 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, }