llm-simple-0.4.2.0: src/LLM/Tools/ReadFilePaginated.hs
module LLM.Tools.ReadFilePaginated
( readFilePaginatedToolTyped,
ReadFilePaginatedArgs (..),
)
where
import Autodocodec qualified as AC
import Data.Aeson (FromJSON)
import Data.ByteString qualified as BS
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding (decodeUtf8With)
import Data.Text.Encoding.Error (lenientDecode)
import GHC.Generics (Generic)
import LLM.Core.Types (TypedTool (..))
import LLM.Tools.FsConfig (FsConfig, sandboxPath, withWorkspacePaths)
import LLM.Tools.FsLimits (maxOutputBytes, maxPaginatedFileBytes)
import LLM.Tools.Internal.TextFile (withTextHandle)
import System.IO
( IOMode (ReadMode),
hFileSize,
withBinaryFile,
)
-- | Default number of lines returned per page when the caller omits 'limit'.
defaultLimit :: Int
defaultLimit = 200
-- | Hard cap on the number of lines returned per page, to protect the
-- model's context window from runaway responses on pathological inputs.
maxLimit :: Int
maxLimit = 2000
data ReadFilePaginatedArgs = ReadFilePaginatedArgs
{ _rfpPath :: Text,
_rfpOffset :: Int,
_rfpLimit :: Int
}
deriving (Generic)
deriving (FromJSON) via (AC.Autodocodec ReadFilePaginatedArgs)
instance AC.HasCodec ReadFilePaginatedArgs where
codec =
AC.object "read a page of a text file" $
ReadFilePaginatedArgs
<$> AC.requiredField "path" "Relative file path to read"
AC..= (._rfpPath)
<*> AC.optionalFieldWithDefault
"offset"
1
"1-based line number to start reading from (default 1)"
AC..= (._rfpOffset)
<*> AC.optionalFieldWithDefault
"limit"
defaultLimit
"Maximum number of lines to return in this page (default 200, hard cap 2000)"
AC..= (._rfpLimit)
readFilePaginatedToolTyped :: FsConfig -> TypedTool ctx ReadFilePaginatedArgs
readFilePaginatedToolTyped cfg =
TypedTool
{ ttoolName = "read_file_paginated",
ttoolDescription =
"Read a slice of a text file by line range (path relative to the workspace). "
<> "Use 'offset' (1-based line number, default 1) and 'limit' (default 200, max 2000) "
<> "to walk through large files one page at a time. Source files larger than "
<> T.pack (show maxPaginatedFileBytes)
<> " bytes are refused. A page holds at most "
<> T.pack (show maxOutputBytes)
<> " bytes of text; a longer line is cut and marked. The response header reports the "
<> "file's byte size, the line range returned, and whether more lines remain along "
<> "with the next offset to use. The tool is stateless: pass the next offset on the "
<> "following call. Binary files are refused.",
ttoolReadonly = True,
ttoolExecute = const (withWorkspacePaths cfg . execute cfg)
}
execute :: FsConfig -> ReadFilePaginatedArgs -> IO Text
execute cfg args
| args._rfpOffset < 1 =
pure $ "Error: offset is a 1-based line number and must be at least 1, got " <> T.pack (show args._rfpOffset)
| args._rfpLimit < 1 =
pure $ "Error: limit must be positive, got " <> T.pack (show args._rfpLimit)
| otherwise = do
let rel = T.unpack args._rfpPath
offset = args._rfpOffset
limit = min maxLimit args._rfpLimit
path = args._rfpPath
capText = T.pack (show maxPaginatedFileBytes)
resolved <- sandboxPath cfg rel
result <- withTextHandle (withBinaryFile resolved ReadMode) $ \h -> do
size <- hFileSize h
if size > maxPaginatedFileBytes
then
pure . Left $
path
<> " is "
<> T.pack (show size)
<> " bytes, which exceeds the "
<> capText
<> "-byte cap for read_file_paginated."
else do
-- Read one byte past the cap, so a file that grew past it since
-- the size check is reported instead of looking like it ended.
bytes <- BS.hGet h (fromIntegral maxPaginatedFileBytes + 1)
pure $
if fromIntegral (BS.length bytes) > maxPaginatedFileBytes
then
Left $
path
<> " grew past the "
<> capText
<> "-byte cap for read_file_paginated during the read."
else
let (lns, hasMore) = takePage offset limit bytes
in Right (size, lns, hasMore)
pure $ case result of
Nothing ->
"Error: "
<> path
<> " appears to be a binary file; read_file_paginated only supports text files."
Just (Left err) -> "Error: " <> err
Just (Right (size, lns, hasMore)) ->
renderPage path size offset lns hasMore
-- | Take up to @limit@ lines starting at line @offset@, and report whether
-- more lines follow.
--
-- Lines are found on the raw bytes (a newline byte never occurs inside a
-- UTF-8 sequence), so the lines before @offset@ are skipped without being
-- decoded. Only the page is decoded, leniently: invalid UTF-8 bytes show
-- as U+FFFD instead of failing the read.
--
-- The page holds at most 'maxOutputBytes' bytes of line text. It ends
-- before a line that would go over, unless that line is the first one;
-- then it is cut and marked, and the next page starts at the next line.
takePage :: Int -> Int -> BS.ByteString -> ([Text], Bool)
takePage offset limit = go 0 0 [] . dropLines (offset - 1)
where
go :: Int -> Int -> [Text] -> BS.ByteString -> ([Text], Bool)
go n used acc bs
| BS.null bs = (reverse acc, False)
| n >= limit = (reverse acc, True)
| used + len > maxOutputBytes =
if null acc
then ([cutLine line], not (BS.null rest))
else (reverse acc, True)
| otherwise = go (n + 1) (used + len) (decode line : acc) rest
where
(line, rest0) = BS.break (== newline) bs
rest = BS.drop 1 rest0
len = BS.length line
dropLines :: Int -> BS.ByteString -> BS.ByteString
dropLines k bs
| k <= 0 = bs
| otherwise = case BS.elemIndex newline bs of
Nothing -> BS.empty
Just i -> dropLines (k - 1) (BS.drop (i + 1) bs)
cutLine line =
decode (utf8Prefix maxOutputBytes line)
<> " [line cut: "
<> T.pack (show (BS.length line))
<> " bytes, first "
<> T.pack (show maxOutputBytes)
<> " shown]"
decode = decodeUtf8With lenientDecode
newline = 10
-- | At most @n@ bytes from the start, without splitting a UTF-8 sequence.
utf8Prefix :: Int -> BS.ByteString -> BS.ByteString
utf8Prefix n bs
| BS.length bs <= n = bs
| otherwise = BS.take (boundary n) bs
where
-- Back up while the byte at the cut is a continuation byte (10xxxxxx),
-- so the cut falls before the lead byte of its sequence.
boundary i
| i > 0 && BS.index bs i >= 0x80 && BS.index bs i < 0xC0 = boundary (i - 1)
| otherwise = i
renderPage :: Text -> Integer -> Int -> [Text] -> Bool -> Text
renderPage path size offset lns hasMore =
let count = length lns
lastNo = offset + count - 1
rangePart =
if count == 0
then "no lines at offset " <> T.pack (show offset)
else "lines " <> T.pack (show offset) <> "-" <> T.pack (show lastNo)
tailPart =
if hasMore
then "more (next offset: " <> T.pack (show (lastNo + 1)) <> ")"
else "end of file"
header =
"=== "
<> path
<> " | "
<> rangePart
<> " | "
<> T.pack (show size)
<> " bytes | "
<> tailPart
<> " ==="
numbered = zipWith fmtLine [offset ..] lns
in T.unlines (header : numbered)
-- | Right-align the line number in a 6-char gutter for readability and
-- so the model can reliably reference specific lines back to other tools.
fmtLine :: Int -> Text -> Text
fmtLine n line =
let s = T.pack (show n)
pad = T.replicate (max 0 (6 - T.length s)) " "
in pad <> s <> "| " <> line