llm-simple-0.4.3.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 (CappedRead (..), readCapped, withTextHandle)
import LLM.Tools.Internal.WalkBudget (utf8Prefix)
import System.IO
( IOMode (ReadMode),
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
capped <- readCapped maxPaginatedFileBytes h
pure $ case capped of
CappedTooLarge size ->
Left $
path
<> " is "
<> T.pack (show size)
<> " bytes, which exceeds the "
<> capText
<> "-byte cap for read_file_paginated."
CappedGrew ->
Left $
path
<> " grew past the "
<> capText
<> "-byte cap for read_file_paginated during the read."
CappedBytes size bytes ->
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
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