summaryrefslogtreecommitdiff
path: root/agent
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-01 11:46:29 -0600
committert <t@tjp.lol>2026-06-01 12:06:43 -0600
commitf4c72eef847b093212c4dd6136cd7e0b1478afe2 (patch)
tree0a484fe0647b3aa451cbfae08ec4ca71b7b41d30 /agent
parent1beefefc69beee214430eb5bd2528a4f5692d2a8 (diff)
luv-based but synchronous std.read tool
Diffstat (limited to 'agent')
-rw-r--r--agent/tools/read.lua235
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