packages feed

llm-simple-0.4.3.0: src/LLM/Tools/Grep.hs

module LLM.Tools.Grep
  ( grepToolTyped,
    GrepToolArgs (..),
  )
where

import Autodocodec qualified as AC
import Control.Exception (IOException, try)
import Control.Monad (unless)
import Data.IORef (IORef, atomicModifyIORef', modifyIORef', newIORef, readIORef)
import Data.Aeson (FromJSON)
import Data.List (sort)
import Data.Text (Text)
import Data.ByteString qualified as BS
import Data.Maybe (catMaybes)
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, isFileHidden, isSymlink, sandboxPath, withWorkspacePaths)
import LLM.Tools.FsLimits (expensiveWalkDepth, maxOutputBytes, maxSearchBytes, maxVisitedEntries)
import LLM.Tools.Internal.TextFile (withTextHandle)
import LLM.Tools.Internal.WalkBudget (WalkBudget, budgetExhausted, budgetNote, capLines, newWalkBudget, takeEntries)
import System.Directory
  ( doesDirectoryExist,
    doesFileExist,
    getFileSize,
    listDirectory,
  )
import System.FilePath (takeExtension, takeFileName, (</>))
import System.IO
  ( IOMode (ReadMode),
    hFileSize,
    withBinaryFile,
  )

-- | Default cap on the number of match lines returned.
defaultMaxResults :: Int
defaultMaxResults = 50

-- | Hard cap on results to protect the model's context window.
maxResultsCap :: Int
maxResultsCap = 500

-- | Skip files larger than this (bytes). Big files are usually generated
-- artifacts (lock files, fixtures) that aren't useful to grep through.
maxFileSizeBytes :: Integer
maxFileSizeBytes = 1024 * 1024 -- 1 MB

-- | Maximum directory depth from the search root. Sourced from
-- 'LLM.Tools.FsLimits.expensiveWalkDepth' because @grep@ reads every
-- encountered file.
maxDepth :: Int
maxDepth = expensiveWalkDepth

-- | Truncate matched line text in output to keep results compact.
maxLineLength :: Int
maxLineLength = 240

data GrepToolArgs = GrepToolArgs
  { _grepPattern :: Text,
    _grepPath :: Text,
    _grepCaseInsensitive :: Bool,
    _grepExtensions :: [Text],
    _grepMaxResults :: Int
  }
  deriving (Generic)
  deriving (FromJSON) via (AC.Autodocodec GrepToolArgs)

instance AC.HasCodec GrepToolArgs where
  codec :: AC.JSONCodec GrepToolArgs
  codec =
    AC.object "search files for a literal substring" $
      GrepToolArgs
        <$> AC.requiredField "pattern" "Literal substring to search for (not a regex)"
          AC..= (._grepPattern)
        <*> AC.optionalFieldWithDefault
          "path"
          "."
          "Relative directory or file to search under (default '.')"
          AC..= (._grepPath)
        <*> AC.optionalFieldWithDefault
          "caseInsensitive"
          False
          "Match case-insensitively (default false)"
          AC..= (._grepCaseInsensitive)
        <*> AC.optionalFieldWithDefault
          "extensions"
          []
          "Restrict to files with these extensions, without the dot, e.g. [\"hs\", \"cabal\"]. Empty means all text files."
          AC..= (._grepExtensions)
        <*> AC.optionalFieldWithDefault
          "maxResults"
          defaultMaxResults
          "Maximum number of match lines to return (default 50, hard cap 500)"
          AC..= (._grepMaxResults)

grepToolTyped :: FsConfig -> TypedTool ctx GrepToolArgs
grepToolTyped cfg =
  TypedTool
    { ttoolName = "grep",
      ttoolDescription =
        "Recursively search files for a literal substring (not a regex). "
          <> "Returns matches as 'relative/path:line: snippet', sorted by file. "
          <> "Skips hidden files, binary files, and files larger than 1 MB. "
          <> "Use 'extensions' to restrict by file type (e.g. [\"hs\"]). "
          <> "Output is capped at 'maxResults' lines (default 50, hard cap 500); "
          <> "the header reports whether results were truncated and how many files were scanned. "
          <> "A search looks at most "
          <> T.pack (show maxVisitedEntries)
          <> " entries, reads at most "
          <> T.pack (show (maxSearchBytes `div` (1024 * 1024)))
          <> " MiB of files, and stops at maxResults; then the file count is shown as 'N+'.",
      ttoolReadonly = True,
      ttoolExecute = const (withWorkspacePaths cfg . execute cfg)
    }

execute :: FsConfig -> GrepToolArgs -> IO Text
execute cfg args = do
  let rel = T.unpack args._grepPath
      pat = args._grepPattern
      ci = args._grepCaseInsensitive
      exts = normalizeExtensions args._grepExtensions
      cap = min maxResultsCap args._grepMaxResults
  if T.null pat
    then pure "Error: pattern must be non-empty"
    else if args._grepMaxResults <= 0
    then pure $ "Error: maxResults must be positive, got " <> T.pack (show args._grepMaxResults)
    else do
      resolved <- sandboxPath cfg rel
      isDir <- doesDirectoryExist resolved
      isFile <- doesFileExist resolved
      -- An explicit file gets the same size cap as files found by the walk.
      sizeOk <- if isFile then isSizeOk resolved else pure True
      if not (isDir || isFile)
        then pure $ "Error: path does not exist: " <> args._grepPath
        else
          if not sizeOk
            then
              pure $
                "Error: "
                  <> args._grepPath
                  <> " exceeds the "
                  <> T.pack (show maxFileSizeBytes)
                  <> "-byte cap for grep."
            else do
              budget <- newWalkBudget maxVisitedEntries
              st <- newIORef (GrepState [] 0 0 False 0 False)
              let search = searchFile st pat ci cap
              if isFile
                then search resolved (takeFileName resolved)
                else walkDir budget st search exts resolved "" 0
              GrepState matches _ scanned capHit bytesRead bytesOut <- readIORef st
              stopped <- budgetExhausted budget
              let notes =
                    catMaybes
                      [ if stopped then Just (budgetNote budget) else Nothing,
                        if bytesOut then Just (bytesNote bytesRead) else Nothing
                      ]
              pure $
                renderResults
                  args._grepPath
                  pat
                  (reverse matches)
                  scanned
                  ((capHit && not isFile) || stopped || bytesOut)
                  capHit
                  (if null notes then Nothing else Just (T.intercalate " | " notes))

-- | Lowercase and strip leading dots so callers can pass "hs" or ".hs".
normalizeExtensions :: [Text] -> [Text]
normalizeExtensions = map (T.toLower . T.dropWhile (== '.'))

-- | Matches so far (newest first), their count, files searched, whether
-- the walk stopped because @maxResults@ was reached, the file bytes read,
-- and whether the walk stopped because 'maxSearchBytes' ran out.
data GrepState = GrepState [Match] Int Int Bool Int Bool

-- | True once the search should look at no more files.
searchDone :: GrepState -> Bool
searchDone (GrepState _ _ _ capHit _ bytesOut) = capHit || bytesOut

-- | The note added to the output when 'maxSearchBytes' ran out.
bytesNote :: Int -> Text
bytesNote n = "stopped after reading " <> T.pack (show n) <> " bytes; narrow the path or pattern"

-- | Walk a directory and search each candidate file as it is found,
-- respecting depth, hidden-file, extension and size limits and the entry
-- budget. Stops once @maxResults@ is reached or 'maxSearchBytes' runs
-- out. Order is stable (entries
-- sorted) so output is deterministic. Each file is shown by its path
-- relative to the search root, built from the names walked, so no host
-- path is shown.
walkDir ::
  WalkBudget ->
  IORef GrepState ->
  (FilePath -> FilePath -> IO ()) ->
  [Text] ->
  FilePath ->
  FilePath ->
  Int ->
  IO ()
walkDir budget st search exts = go
  where
    go dir display depth
      | depth > maxDepth = pure ()
      | otherwise = do
          entries <- sort <$> safeListDirectory dir
          allowed <- takeEntries budget (filter (not . isFileHidden) entries)
          mapM_ (visit dir display depth) allowed
    visit parent display depth name = do
      done <- searchDone <$> readIORef st
      unless done $ do
        let full = parent </> name
            fullDisplay = display </> name
        -- Skip symlinks to prevent traversal out of the sandbox.
        isLink <- isSymlink full
        unless isLink $ do
          isDir <- doesDirectoryExist full
          if isDir
            then go full fullDisplay (depth + 1)
            else do
              isFile <- doesFileExist full
              ok <-
                if isFile && extensionAllowed exts name
                  then isSizeOk full
                  else pure False
              if ok then search full fullDisplay else pure ()

extensionAllowed :: [Text] -> FilePath -> Bool
extensionAllowed [] _ = True
extensionAllowed exts name =
  let ext = T.toLower (T.pack (dropDot (takeExtension name)))
   in ext `elem` exts
  where
    dropDot ('.' : rest) = rest
    dropDot s = s

isSizeOk :: FilePath -> IO Bool
isSizeOk path = do
  r <- try (getFileSize path) :: IO (Either IOException Integer)
  case r of
    Right sz -> pure (sz <= maxFileSizeBytes)
    Left _ -> pure False

safeListDirectory :: FilePath -> IO [FilePath]
safeListDirectory path = do
  r <- try (listDirectory path) :: IO (Either IOException [FilePath])
  case r of
    Right xs -> pure xs
    Left _ -> pure []

-- | Search one file and add its matches, up to the cap. Binary and
-- unreadable files count as searched but give no matches. A text file is
-- charged to 'maxSearchBytes' before it is read; a file that does not fit
-- is not read or counted, and ends the search.
searchFile :: IORef GrepState -> Text -> Bool -> Int -> FilePath -> FilePath -> IO ()
searchFile st pat ci cap f display = do
  r <-
    try (withTextHandle (withBinaryFile f ReadMode) readCharged) ::
      IO (Either IOException (Maybe (Maybe Text)))
  case r of
    Right (Just Nothing) -> pure ()
    _ -> do
      let ms = case r of
            Right (Just (Just content)) -> matchLines display content needle ci
            _ -> []
      modifyIORef' st $ \(GrepState acc n scanned _ used out) ->
        let remaining = cap - n
            taken = take remaining ms
            n' = n + length taken
         in GrepState (reverse taken ++ acc) n' (scanned + 1) (n' >= cap) used out
  where
    needle = if ci then T.toLower pat else pat
    -- Charge the file's size (at most the per-file cap) and read that
    -- many bytes, or refuse it when the budget cannot cover it.
    readCharged h = do
      sz <- min maxFileSizeBytes <$> hFileSize h
      let n = fromIntegral sz
      fits <- charge n
      if fits
        then Just . decodeUtf8With lenientDecode <$> BS.hGet h n
        else pure Nothing
    charge n = atomicModifyIORef' st $ \(GrepState acc k scanned capHit used out) ->
      if used + n <= maxSearchBytes
        then (GrepState acc k scanned capHit (used + n) out, True)
        else (GrepState acc k scanned capHit used True, False)

data Match = Match
  { mPath :: FilePath,
    mLine :: Int,
    mText :: Text
  }

matchLines :: FilePath -> Text -> Text -> Bool -> [Match]
matchLines path content needle ci =
  [ Match path n (truncateLine line)
    | (n, line) <- zip [1 ..] (T.lines content),
      let hay = if ci then T.toLower line else line,
      needle `T.isInfixOf` hay
  ]

truncateLine :: Text -> Text
truncateLine t
  | T.length t <= maxLineLength = t
  | otherwise = T.take maxLineLength t <> "…"

-- | Render the results. @partial@: the walk stopped early, so the file
-- count is a lower bound. @capHit@: @maxResults@ was reached.
renderResults :: Text -> Text -> [Match] -> Int -> Bool -> Bool -> Maybe Text -> Text
renderResults rootDisplay pat allMatches scanned partial capHit note =
  let (body, cut) = capLines maxOutputBytes (map fmtMatch allMatches)
      header =
        "=== grep "
          <> T.pack (show pat)
          <> " in "
          <> rootDisplay
          <> " | "
          <> T.pack (show (length body))
          <> " match(es) across "
          <> T.pack (show scanned)
          <> (if partial then "+" else "")
          <> " file(s)"
          <> ( if cut
                 then " | output truncated at " <> T.pack (show maxOutputBytes) <> " bytes"
                 else if capHit then " | stopped at maxResults (raise it to see more)" else ""
             )
          <> maybe "" (" | " <>) note
          <> " ==="
   in T.unlines (header : body)
  where
    fmtMatch m =
      T.pack m.mPath
        <> ":"
        <> T.pack (show m.mLine)
        <> ": "
        <> m.mText