diff options
| author | t <t@tjp.lol> | 2026-06-01 11:46:29 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-01 12:06:43 -0600 |
| commit | f4c72eef847b093212c4dd6136cd7e0b1478afe2 (patch) | |
| tree | 0a484fe0647b3aa451cbfae08ec4ca71b7b41d30 /agent/tools | |
| parent | 1beefefc69beee214430eb5bd2528a4f5692d2a8 (diff) | |
luv-based but synchronous std.read tool
Diffstat (limited to 'agent/tools')
| -rw-r--r-- | agent/tools/read.lua | 235 |
1 files changed, 185 insertions, 50 deletions
diff --git a/agent/tools/read.lua b/agent/tools/read.lua index 05661c4..d345f99 100644 --- a/agent/tools/read.lua +++ b/agent/tools/read.lua @@ -3,28 +3,124 @@ -- 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 --- context window is worse than one that silently asks for a follow-up --- scoped read. +-- 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. The file body itself is byte-for-byte what's on disk, so --- `edit` anchors round-trip exactly. +-- 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") local MAX_BYTES = 50 * 1024 -- 50 KB local MAX_LINES = 2000 +local READ_CHUNK_SIZE = MAX_BYTES + +local function scandir_sorted(path) + local req, err = uv.fs_scandir(path) + if not req then + return nil, err + end + + local entries = {} + while true do + local name, typ = uv.fs_scandir_next(req) + if not name then break end + entries[#entries + 1] = { name = name, typ = typ } + end + + 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; on truncation a marker names the next offset for a follow-up call. Use offset/limit to slice.", + 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.", schema = { type = "object", properties = { - path = { type = "string", description = "Path to the file (relative or absolute)." }, + 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 number of lines to return.", minimum = 1 }, + limit = { type = "integer", description = "Maximum lines to return.", minimum = 1 }, }, required = { "path" }, }, @@ -40,68 +136,104 @@ return { return string.format("Error: limit (%d) must be >= 1.", limit) end - local f, open_err = io.open(path, "rb") - if not f then + local stat, stat_err = uv.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 fd, open_err = uv.fs_open(path, "r", 0) + if not fd 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 truncated_reason = nil 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 + 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 - -- 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 + 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 data, err = uv.fs_read(fd, READ_CHUNK_SIZE, file_offset) + if err then + read_err = err + break + end + if data == nil then + if #carry > 0 then + process_line(carry) + carry = "" 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 + 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 - f:close() + + uv.fs_close(fd) + + if read_err then + return "Error: read failed: " .. tostring(read_err) + end if #parts == 0 then - if lineno == 0 then + if not saw_any_bytes then return "(empty file)\n" end if offset > lineno then @@ -110,6 +242,9 @@ return { 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 |
