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