summaryrefslogtreecommitdiff
path: root/agent/tools/read.lua
blob: 05661c4f607d52c729827226886c93bf29f6c0b9 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
-- Read a file from disk and return its text verbatim.
--
-- 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.
--
-- 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.

local MAX_BYTES = 50 * 1024   -- 50 KB
local MAX_LINES = 2000

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.",
    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,
}