diff options
| author | t <t@tjp.lol> | 2026-06-15 22:11:53 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-16 11:17:14 -0600 |
| commit | f8c6d45755acb5abf9ac68931268b7945da0fae0 (patch) | |
| tree | a88ed7edd4aa3e5acced9ff99ac61b7f36b267a6 /agent | |
| parent | 8206642d3a1736aaf928a5b9a732e747f5294228 (diff) | |
display fixes: markdown rendering, tool-specific components
Diffstat (limited to 'agent')
| -rw-r--r-- | agent/extensions/std_render.lua | 193 | ||||
| -rw-r--r-- | agent/tools/edit.lua | 297 | ||||
| -rw-r--r-- | agent/tools/read.lua | 351 | ||||
| -rw-r--r-- | agent/tools/shell.lua | 40 | ||||
| -rw-r--r-- | agent/tools/write.lua | 101 |
5 files changed, 605 insertions, 377 deletions
diff --git a/agent/extensions/std_render.lua b/agent/extensions/std_render.lua new file mode 100644 index 0000000..d82ccfa --- /dev/null +++ b/agent/extensions/std_render.lua @@ -0,0 +1,193 @@ +-- Rendering components for the bundled std.* tools. +-- +-- The Zig TUI core deliberately renders tools generically. Tool-specific +-- presentation belongs beside the tool implementation and is installed through +-- the extension event system. + +local panto = require("panto") + +local ok_json, json = pcall(require, "dkjson") +if not ok_json then + ok_json, json = pcall(require, "luarocks.vendor.dkjson") +end + +local states_by_index = {} +local states_by_id = {} +local index_by_id = {} + +local function decode(s) + if type(s) ~= "string" or s == "" or not ok_json then return nil end + local ok, obj = pcall(json.decode, s) + if ok and type(obj) == "table" then return obj end + return nil +end + +local function short_name(name) + if type(name) ~= "string" then return nil end + return name:match("^std%.(.+)$") or name:match("^std__([%w_%-]+)$") or name +end + +local function is_ours(name) + local s = short_name(name) + return s == "read" or s == "write" or s == "shell" or s == "edit" +end + +local function spaces(n) return string.rep(" ", math.max(0, n or 0)) end + +local function wrap_text(text, width) + width = math.max(1, width or 1) + text = tostring(text or "") + local out = {} + for raw in (text .. "\n"):gmatch("(.-)\n") do + if raw == "" then + out[#out + 1] = "" + else + local line = raw + while #line > width do + local cut = width + local prefix = line:sub(1, width) + local sp = prefix:match("^.*() %S") + if sp and sp > 1 then cut = sp - 1 end + out[#out + 1] = line:sub(1, cut) + line = line:sub(cut + 1):gsub("^%s+", "") + end + out[#out + 1] = line + end + end + if #out == 0 then out[1] = "" end + return out +end + +local function quote(v) + if v == nil or v == "" then return nil end + return tostring(v) +end + +local function derive_header(st) + local name = short_name(st.name) or st.name or "?" + local obj = decode(st.input) + if name == "read" and obj then + local path = quote(obj.path) + if path then + if type(obj.offset) == "number" and type(obj.limit) == "number" then + return string.format("read %s (lines %d-%d)", path, obj.offset, obj.offset + obj.limit - 1) + elseif type(obj.offset) == "number" then + return string.format("read %s (from line %d)", path, obj.offset) + end + return "read " .. path + end + elseif name == "write" and obj and quote(obj.path) then + return "write " .. quote(obj.path) + elseif name == "shell" and obj and quote(obj.command) then + local tail = "" + if quote(obj.cwd) then tail = tail .. " cwd=" .. quote(obj.cwd) end + if type(obj.timeout) == "number" then tail = tail .. string.format(" timeout=%ds", obj.timeout) end + return "shell $ " .. quote(obj.command) .. tail + elseif name == "edit" and obj and quote(obj.path) then + local n = type(obj.edits) == "table" and #obj.edits or 0 + if n == 1 then return "edit " .. quote(obj.path) .. " (1 edit)" end + if n > 1 then return string.format("edit %s (%d edits)", quote(obj.path), n) end + return "edit " .. quote(obj.path) + end + return string.format("tool (%s) %s", st.name or "?", st.input or "") +end + +local function header(st) + return st.header_text or derive_header(st) +end + +local function edit_diff(input) + local obj = decode(input) + if not obj or type(obj.edits) ~= "table" then return nil end + local out = {} + for _, e in ipairs(obj.edits) do + if type(e) == "table" then + if type(e.old) == "string" then + for l in (e.old .. "\n"):gmatch("(.-)\n") do if l ~= "" then out[#out + 1] = "-" .. l end end + end + if type(e.new) == "string" then + for l in (e.new .. "\n"):gmatch("(.-)\n") do if l ~= "" then out[#out + 1] = "+" .. l end end + end + end + end + return table.concat(out, "\n") +end + +local function component(st) + return { + render = function(_, width) + local inner = math.max(1, (width or 1) - 2) + local lines = { "" } + local function add(s) + for _, l in ipairs(wrap_text(s, inner)) do + lines[#lines + 1] = " " .. l .. spaces(inner - #l + 1) + end + end + add(header(st)) + lines[#lines + 1] = " " .. spaces(inner) .. " " + local body + if short_name(st.name) == "edit" and st.output then + body = edit_diff(st.input) + end + body = body or st.output or "(…)" + add(body) + lines[#lines + 1] = "" + return lines + end, + } +end + +local function state_for_event(e) + local idx = e.index + if not idx and e.id then idx = index_by_id[e.id] end + + local st = nil + if e.id then st = states_by_id[e.id] end + if not st and idx then st = states_by_index[idx] end + + -- Block indexes are per-turn/per-message stream positions and are reused + -- across later assistant turns. Tool-call ids, however, are unique for the + -- actual call. If a new call reuses an old index, discard the old render + -- state; otherwise the pending component temporarily shows the previous + -- tool's output until the new result arrives. + if st and e.id and st.id and st.id ~= e.id then + st = nil + end + + if not st then + st = { index = idx, input = "" } + end + if idx then + st.index = idx + states_by_index[idx] = st + end + if e.tool_name then st.name = e.tool_name end + if e.id then + st.id = e.id + index_by_id[e.id] = idx or st.index + states_by_id[e.id] = st + end + if e.input then st.input = e.input end + if e.delta then st.input = (st.input or "") .. e.delta end + if e.output then st.output = e.output end + if st.name or st.input ~= "" then st.header_text = derive_header(st) end + return st +end + +local function on_tool_event(e) + local name = e.tool_name + if not name and e.id then + local idx = index_by_id[e.id] + local st = idx and states_by_index[idx] + name = st and st.name + end + if not is_ours(name) then return end + local st = state_for_event(e) + if not st then return end + e:set_component(component(st)) +end + +panto.ext.on("tool_details", on_tool_event) +panto.ext.on("tool_delta", on_tool_event) +panto.ext.on("tool_call_complete", on_tool_event) +panto.ext.on("tool_result", on_tool_event) diff --git a/agent/tools/edit.lua b/agent/tools/edit.lua index ec96aa6..b32516a 100644 --- a/agent/tools/edit.lua +++ b/agent/tools/edit.lua @@ -10,8 +10,39 @@ -- All matches are evaluated against the original file, not -- progressively against the result of earlier entries. +local panto = require("panto") local uv = require("luv") +local tool = { + name = "std.edit", + description = "Apply exact-text replacements to a file. Each entry's `old` must match exactly once in the original file (not progressively). Edits whose ranges overlap, match zero times, or match multiple times reject the entire call — no partial edits, file untouched. Keep `old` minimal but unique; widen with surrounding context if needed.", + schema = { + type = "object", + properties = { + path = { type = "string", description = "Path to the file (relative or absolute)." }, + edits = { + type = "array", + description = "Replacements applied atomically.", + minItems = 1, + items = { + type = "object", + properties = { + old = { type = "string", description = "Exact substring to find; must appear exactly once." }, + new = { type = "string", description = "Replacement text (may be empty to delete)." }, + }, + required = { "old", "new" }, + }, + }, + }, + required = { "path", "edits" }, + }, +} + +panto.ext.on("tool", function(e) + if e.tool_name ~= tool.name then return end + e:set_component(e:get_component()) +end) + -- --------------------------------------------------------------------------- -- Coroutine-synchronous libuv wrappers. panto runs each tool handler in -- its own coroutine and drives a single `uv.run()` to completion; a @@ -86,170 +117,148 @@ local function write_all(path, content) return true, nil end -return { - name = "std.edit", - description = "Apply exact-text replacements to a file. Each entry's `old` must match exactly once in the original file (not progressively). Edits whose ranges overlap, match zero times, or match multiple times reject the entire call — no partial edits, file untouched. Keep `old` minimal but unique; widen with surrounding context if needed.", - schema = { - type = "object", - properties = { - path = { type = "string", description = "Path to the file (relative or absolute)." }, - edits = { - type = "array", - description = "Replacements applied atomically.", - minItems = 1, - items = { - type = "object", - properties = { - old = { type = "string", description = "Exact substring to find; must appear exactly once." }, - new = { type = "string", description = "Replacement text (may be empty to delete)." }, - }, - required = { "old", "new" }, - }, - }, - }, - required = { "path", "edits" }, - }, - handler = function(input) - local path = input.path - local edits = input.edits +tool.handler = function(input) + local path = input.path + local edits = input.edits - if type(path) ~= "string" or path == "" then - return "Error: `path` must be a non-empty string." - end - if type(edits) ~= "table" or #edits == 0 then - return "Error: `edits` must be a non-empty array." - end + if type(path) ~= "string" or path == "" then + return "Error: `path` must be a non-empty string." + end + if type(edits) ~= "table" or #edits == 0 then + return "Error: `edits` must be a non-empty array." + end - -- Load the file. - local original, read_err = read_all(path) - if not original then - return "Error: " .. (read_err or ("could not read " .. path)) - end + -- Load the file. + local original, read_err = read_all(path) + if not original then + return "Error: " .. (read_err or ("could not read " .. path)) + end - -- Validate every entry first, collecting the byte-range each - -- `before` resolves to. We need this in two passes: - -- 1. uniqueness check (count occurrences) - -- 2. overlap check (compare resolved ranges pairwise) - local problems = {} -- list of "edit #N: ..." strings - local ranges = {} -- per-edit { start_byte, end_byte } or nil + -- Validate every entry first, collecting the byte-range each + -- `before` resolves to. We need this in two passes: + -- 1. uniqueness check (count occurrences) + -- 2. overlap check (compare resolved ranges pairwise) + local problems = {} -- list of "edit #N: ..." strings + local ranges = {} -- per-edit { start_byte, end_byte } or nil - for i, e in ipairs(edits) do - if type(e) ~= "table" then - problems[#problems + 1] = string.format( - "edit #%d: entry is not an object", i - ) - ranges[i] = nil - elseif type(e.old) ~= "string" or type(e.new) ~= "string" then + for i, e in ipairs(edits) do + if type(e) ~= "table" then + problems[#problems + 1] = string.format( + "edit #%d: entry is not an object", i + ) + ranges[i] = nil + elseif type(e.old) ~= "string" or type(e.new) ~= "string" then + problems[#problems + 1] = string.format( + "edit #%d: `old` and `new` must both be strings", i + ) + ranges[i] = nil + elseif e.old == "" then + problems[#problems + 1] = string.format( + "edit #%d: `old` is the empty string (would be ambiguous)", i + ) + ranges[i] = nil + else + -- string.find with `plain = true` skips pattern parsing, + -- so the search is literal. Count occurrences by walking. + local count = 0 + local first_start, first_end = nil, nil + local search_from = 1 + while true do + local s, ee = string.find(original, e.old, search_from, true) + if not s then break end + count = count + 1 + if count == 1 then + first_start, first_end = s, ee + end + search_from = ee + 1 + end + if count == 0 then + -- Truncate the previewed snippet so the error stays + -- legible in tool output. + local snippet = e.old + if #snippet > 80 then + snippet = snippet:sub(1, 77) .. "..." + end problems[#problems + 1] = string.format( - "edit #%d: `old` and `new` must both be strings", i + "edit #%d: `old` not found in file. Snippet: %q", + i, snippet ) ranges[i] = nil - elseif e.old == "" then + elseif count > 1 then problems[#problems + 1] = string.format( - "edit #%d: `old` is the empty string (would be ambiguous)", i + "edit #%d: `old` matches %d times (need exactly 1). " .. + "Widen with surrounding context to disambiguate.", + i, count ) ranges[i] = nil else - -- string.find with `plain = true` skips pattern parsing, - -- so the search is literal. Count occurrences by walking. - local count = 0 - local first_start, first_end = nil, nil - local search_from = 1 - while true do - local s, ee = string.find(original, e.old, search_from, true) - if not s then break end - count = count + 1 - if count == 1 then - first_start, first_end = s, ee - end - search_from = ee + 1 - end - if count == 0 then - -- Truncate the previewed snippet so the error stays - -- legible in tool output. - local snippet = e.old - if #snippet > 80 then - snippet = snippet:sub(1, 77) .. "..." - end - problems[#problems + 1] = string.format( - "edit #%d: `old` not found in file. Snippet: %q", - i, snippet - ) - ranges[i] = nil - elseif count > 1 then - problems[#problems + 1] = string.format( - "edit #%d: `old` matches %d times (need exactly 1). " .. - "Widen with surrounding context to disambiguate.", - i, count - ) - ranges[i] = nil - else - ranges[i] = { first_start, first_end } - end + ranges[i] = { first_start, first_end } end end + end - -- Overlap check (only meaningful if we got this far without - -- per-edit problems; otherwise the user already has plenty to - -- fix and overlap noise would be confusing). - if #problems == 0 then - for i = 1, #ranges do - for j = i + 1, #ranges do - local a, b = ranges[i], ranges[j] - if a and b and not (a[2] < b[1] or b[2] < a[1]) then - problems[#problems + 1] = string.format( - "edits #%d and #%d target overlapping regions " .. - "(bytes %d-%d and %d-%d). Combine them into one " .. - "edit with a wider `old`.", - i, j, a[1], a[2], b[1], b[2] - ) - end + -- Overlap check (only meaningful if we got this far without + -- per-edit problems; otherwise the user already has plenty to + -- fix and overlap noise would be confusing). + if #problems == 0 then + for i = 1, #ranges do + for j = i + 1, #ranges do + local a, b = ranges[i], ranges[j] + if a and b and not (a[2] < b[1] or b[2] < a[1]) then + problems[#problems + 1] = string.format( + "edits #%d and #%d target overlapping regions " .. + "(bytes %d-%d and %d-%d). Combine them into one " .. + "edit with a wider `old`.", + i, j, a[1], a[2], b[1], b[2] + ) end end end + end - if #problems > 0 then - return "Error: edit rejected (" .. #problems .. " issue" .. - (#problems == 1 and "" or "s") .. "); file not modified.\n" .. - " - " .. table.concat(problems, "\n - ") .. "\n" - end + if #problems > 0 then + return "Error: edit rejected (" .. #problems .. " issue" .. + (#problems == 1 and "" or "s") .. "); file not modified.\n" .. + " - " .. table.concat(problems, "\n - ") .. "\n" + end - -- Apply edits. We sort by start position ascending and rebuild - -- the file from non-overlapping slices. This avoids the O(N*M) - -- repeated-substring-substitution and keeps each edit's - -- `before` matched against the original (not the in-progress - -- result). - local order = {} - for i = 1, #edits do order[i] = i end - table.sort(order, function(a, b) - return ranges[a][1] < ranges[b][1] - end) + -- Apply edits. We sort by start position ascending and rebuild + -- the file from non-overlapping slices. This avoids the O(N*M) + -- repeated-substring-substitution and keeps each edit's + -- `before` matched against the original (not the in-progress + -- result). + local order = {} + for i = 1, #edits do order[i] = i end + table.sort(order, function(a, b) + return ranges[a][1] < ranges[b][1] + end) - local out = {} - local cursor = 1 - for _, idx in ipairs(order) do - local r = ranges[idx] - if r[1] > cursor then - out[#out + 1] = original:sub(cursor, r[1] - 1) - end - out[#out + 1] = edits[idx].new - cursor = r[2] + 1 - end - if cursor <= #original then - out[#out + 1] = original:sub(cursor) + local out = {} + local cursor = 1 + for _, idx in ipairs(order) do + local r = ranges[idx] + if r[1] > cursor then + out[#out + 1] = original:sub(cursor, r[1] - 1) end - local new_content = table.concat(out) + out[#out + 1] = edits[idx].new + cursor = r[2] + 1 + end + if cursor <= #original then + out[#out + 1] = original:sub(cursor) + end + local new_content = table.concat(out) - -- Write back. - local ok, write_err = write_all(path, new_content) - if not ok then - return "Error: file read OK but write failed: " .. - tostring(write_err) - end + -- Write back. + local ok, write_err = write_all(path, new_content) + if not ok then + return "Error: file read OK but write failed: " .. + tostring(write_err) + end - return string.format( - "Applied %d edit%s to %s. File is now %d bytes (was %d).", - #edits, (#edits == 1 and "" or "s"), path, #new_content, #original - ) - end, -} + return string.format( + "Applied %d edit%s to %s. File is now %d bytes (was %d).", + #edits, (#edits == 1 and "" or "s"), path, #new_content, #original + ) +end + +return tool diff --git a/agent/tools/read.lua b/agent/tools/read.lua index 0328efc..f691f44 100644 --- a/agent/tools/read.lua +++ b/agent/tools/read.lua @@ -1,3 +1,25 @@ +local panto = require("panto") +local uv = require("luv") + +local tool = { + 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" }, + }, +} + +panto.ext.on("tool", function(e) + if e.tool_name ~= tool.name then return end + e:set_component(e:get_component()) +end) + -- Read a file from disk and return its text verbatim. -- -- Output cap: 50 KB and 2000 lines, whichever is hit first. Large @@ -13,8 +35,6 @@ -- 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 @@ -216,197 +236,186 @@ local function render_directory_listing(path, offset, limit) 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" +tool.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 + 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 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 + 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) + -- 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 { - text = string.format("[read %s as binary attachment]", path), - attachments = { { data = whole } }, - } + 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 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 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 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 + 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 + 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 + 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 + 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 + 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) + fs_close(fd) - if read_err then - return "Error: read failed: " .. tostring(read_err) - end + 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" + if #parts == 0 then + if not saw_any_bytes then + return "(empty file)\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 + if offset > lineno then + return string.format( + "Error: offset (%d) is past end of file (%d lines).", + offset, lineno ) end - return body - 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 + +return tool diff --git a/agent/tools/shell.lua b/agent/tools/shell.lua index f9be58b..6c0a37c 100644 --- a/agent/tools/shell.lua +++ b/agent/tools/shell.lua @@ -26,12 +26,32 @@ -- a string-returning tool result is shaped). Most shell commands the -- agent runs interleave the two anyway. +local panto = require("panto") local uv = require("luv") local MAX_BYTES = 50 * 1024 -- 50 KB, same as `read` local DEFAULT_TIMEOUT_S = 300 -- 5 minutes local SIGKILL_GRACE_MS = 250 +local tool = { + name = "std.shell", + description = string.format("Execute a shell command via `/bin/sh -c`. Returns merged stdout+stderr after the command exits, prefixed with an exit-status header. Output is truncated to the last %dKB; on truncation the full transcript is saved to `$PANTO_HOME/shell-output/` and its path is included for follow-up `read` calls. Default timeout 5 minutes.", MAX_BYTES / 1024), + schema = { + type = "object", + properties = { + command = { type = "string", description = "Shell command. Passed to `sh -c` verbatim; pipes, redirections, etc. work." }, + cwd = { type = "string", description = "Working directory (default: cwd)." }, + timeout = { type = "integer", description = "Hard timeout in seconds (default: " .. tostring(DEFAULT_TIMEOUT_S) .. ").", minimum = 1 }, + }, + required = { "command" }, + }, +} + +panto.ext.on("tool", function(e) + if e.tool_name ~= tool.name then return end + e:set_component(e:get_component()) +end) + -- --------------------------------------------------------------------------- -- Coroutine-synchronous coordination. -- @@ -128,20 +148,7 @@ end -- --------------------------------------------------------------------------- -- Tool body -- --------------------------------------------------------------------------- - -return { - name = "std.shell", - description = string.format("Execute a shell command via `/bin/sh -c`. Returns merged stdout+stderr after the command exits, prefixed with an exit-status header. Output is truncated to the last %dKB; on truncation the full transcript is saved to `$PANTO_HOME/shell-output/` and its path is included for follow-up `read` calls. Default timeout 5 minutes.", MAX_BYTES / 1024), - schema = { - type = "object", - properties = { - command = { type = "string", description = "Shell command. Passed to `sh -c` verbatim; pipes, redirections, etc. work." }, - cwd = { type = "string", description = "Working directory (default: cwd)." }, - timeout = { type = "integer", description = "Hard timeout in seconds (default: " .. tostring(DEFAULT_TIMEOUT_S) .. ").", minimum = 1 }, - }, - required = { "command" }, - }, - handler = function(input) +tool.handler = function(input) local command = input.command if type(command) ~= "string" or command == "" then return "Error: `command` must be a non-empty string." @@ -427,5 +434,6 @@ return { end end return table.concat(parts, "\n") - end, -} + end + +return tool diff --git a/agent/tools/write.lua b/agent/tools/write.lua index 42cb85a..144a305 100644 --- a/agent/tools/write.lua +++ b/agent/tools/write.lua @@ -2,8 +2,27 @@ -- description terse because every byte of every tool description rides -- in the system prompt on every turn. +local panto = require("panto") local uv = require("luv") +local tool = { + name = "std.write", + description = "Save content to a path. Overwrites existing files; missing parent directories are created automatically.", + schema = { + type = "object", + properties = { + path = { type = "string", description = "Path to the file (relative or absolute)." }, + content = { type = "string", description = "Exact bytes to write. No newline is added." }, + }, + required = { "path", "content" }, + }, +} + +panto.ext.on("tool", function(e) + if e.tool_name ~= tool.name then return end + e:set_component(e:get_component()) +end) + -- --------------------------------------------------------------------------- -- Coroutine-synchronous libuv wrappers. panto runs each tool handler in -- its own coroutine and drives a single `uv.run()` to completion; a @@ -68,57 +87,47 @@ local function dirname(path) return path:sub(1, slash - 1) end -return { - name = "std.write", - description = "Save content to a path. Overwrites existing files; missing parent directories are created automatically.", - schema = { - type = "object", - properties = { - path = { type = "string", description = "Path to the file (relative or absolute)." }, - content = { type = "string", description = "Exact bytes to write. No newline is added." }, - }, - required = { "path", "content" }, - }, - handler = function(input) - local path = input.path - local content = input.content +tool.handler = function(input) + local path = input.path + local content = input.content - if type(path) ~= "string" or path == "" then - return "Error: `path` must be a non-empty string." - end - if type(content) ~= "string" then - return "Error: `content` must be a string." - end + if type(path) ~= "string" or path == "" then + return "Error: `path` must be a non-empty string." + end + if type(content) ~= "string" then + return "Error: `content` must be a string." + end - local parent = dirname(path) - if parent then - local ok, err = mkdir_p(parent) - if not ok then - return "Error: could not create parent directory " .. parent .. ": " .. tostring(err) - end + local parent = dirname(path) + if parent then + local ok, err = mkdir_p(parent) + if not ok then + return "Error: could not create parent directory " .. parent .. ": " .. tostring(err) end + end - local open_err, fd = fs_open(path, "w", tonumber("644", 8)) - if not fd then - return "Error: " .. (open_err or ("could not open " .. path .. " for writing")) - end + local open_err, fd = fs_open(path, "w", tonumber("644", 8)) + if not fd then + return "Error: " .. (open_err or ("could not open " .. path .. " for writing")) + end - -- A single `fs_write` may short-write; loop until all bytes land. - local offset = 0 - while offset < #content do - local werr, n = fs_write(fd, content:sub(offset + 1), offset) - if not n then - fs_close(fd) - return "Error: write failed: " .. tostring(werr) - end - offset = offset + n + -- A single `fs_write` may short-write; loop until all bytes land. + local offset = 0 + while offset < #content do + local werr, n = fs_write(fd, content:sub(offset + 1), offset) + if not n then + fs_close(fd) + return "Error: write failed: " .. tostring(werr) end + offset = offset + n + end - local close_err = fs_close(fd) - if close_err then - return "Error: close failed: " .. tostring(close_err) - end + local close_err = fs_close(fd) + if close_err then + return "Error: close failed: " .. tostring(close_err) + end - return string.format("Wrote %d bytes to %s.", #content, path) - end, -} + return string.format("Wrote %d bytes to %s.", #content, path) +end + +return tool |
