From 48651e123ef0cf1d02eac781902517c0628b310a Mon Sep 17 00:00:00 2001 From: T Date: Wed, 27 May 2026 17:07:49 -0600 Subject: real coding agent tools --- agent/tools/read.lua | 139 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 agent/tools/read.lua (limited to 'agent/tools/read.lua') 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, +} -- cgit v1.3