summaryrefslogtreecommitdiff
path: root/agent/tools/read.lua
diff options
context:
space:
mode:
authorT <t@tjp.lol>2026-05-27 17:07:49 -0600
committerT <t@tjp.lol>2026-05-27 18:05:12 -0600
commit48651e123ef0cf1d02eac781902517c0628b310a (patch)
treef8f87205b2ca716f406ae69288ebef622659dd9e /agent/tools/read.lua
parentf72b5793a5c7e7891e4904be73c0bc6bc038fa18 (diff)
real coding agent tools
Diffstat (limited to 'agent/tools/read.lua')
-rw-r--r--agent/tools/read.lua139
1 files changed, 139 insertions, 0 deletions
diff --git a/agent/tools/read.lua b/agent/tools/read.lua
new file mode 100644
index 0000000..e6192ae
--- /dev/null
+++ b/agent/tools/read.lua
@@ -0,0 +1,139 @@
+-- Read a file from disk and return its text verbatim.
+--
+-- Output cap: 50 KB and 2000 lines, whichever is hit first. Matches
+-- pi's defaults; 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 — and matches pi's tool surface. 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.
+
+local MAX_BYTES = 50 * 1024 -- 50 KB
+local MAX_LINES = 2000
+
+return {
+ name = "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.",
+ schema = {
+ type = "object",
+ properties = {
+ path = { type = "string", description = "Path to the file (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 },
+ },
+ 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 f, open_err = io.open(path, "rb")
+ if not f 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 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
+ 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
+ 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
+ end
+ end
+ end
+ f:close()
+
+ if #parts == 0 then
+ if lineno == 0 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
+ 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,
+}