packages feed

llm-simple-0.4.2.0: src/LLM/Tools/Readdir.hs

module LLM.Tools.Readdir (readdirToolTyped, ReaddirToolArgs (..)) where

import Autodocodec qualified as AC
import Data.Aeson (FromJSON)
import Data.List (sort)
import Data.Text (Text)
import Data.Text qualified as T
import GHC.Generics (Generic)
import LLM.Core.Types (TypedTool (..))
import LLM.Tools.FsConfig (FsConfig, isFileHidden, isSymlink, sandboxPath, withWorkspacePaths)
import LLM.Tools.FsLimits (maxOutputBytes, maxTreeEntries)
import LLM.Tools.Internal.WalkBudget (capLines)
import System.Directory (doesDirectoryExist, listDirectory)
import System.FilePath ((</>))

newtype ReaddirToolArgs = ReaddirToolArgs
  { _rdPath :: Text
  }
  deriving (Generic)
  deriving (FromJSON) via (AC.Autodocodec ReaddirToolArgs)

instance AC.HasCodec ReaddirToolArgs where
  codec :: AC.JSONCodec ReaddirToolArgs
  codec =
    AC.object "list a directory" $
      ReaddirToolArgs <$> AC.requiredField "path" "Relative directory path to list" AC..= (._rdPath)

readdirToolTyped :: FsConfig -> TypedTool ctx ReaddirToolArgs
readdirToolTyped fsConfig =
  TypedTool
    { ttoolName = "readdir",
      ttoolDescription =
        "List the contents of a directory (relative to the workspace). "
          <> "Returns one entry per line. Directories are suffixed with '/', "
          <> "symbolic links with '@' (links are not followed). "
          <> "Use path '.' to list the workspace root. "
          <> "At most "
          <> T.pack (show maxTreeEntries)
          <> " entries are listed; a final line says if more were left out.",
      ttoolReadonly = True,
      ttoolExecute = const (withWorkspacePaths fsConfig . readdirExecTyped fsConfig)
    }

readdirExecTyped :: FsConfig -> ReaddirToolArgs -> IO Text
readdirExecTyped cfg args = do
  let relPath = T.unpack args._rdPath
  resolved <- sandboxPath cfg relPath
  visible <- sort . filter (not . isFileHidden) <$> listDirectory resolved
  annotated <- mapM (annotateEntry resolved) (take maxTreeEntries visible)
  -- Leave room for the note line.
  let (shown, _) = capLines (maxOutputBytes - 200) annotated
      total = length visible
      note
        | length shown < total =
            [ "[showing "
                <> T.pack (show (length shown))
                <> " of "
                <> T.pack (show total)
                <> " entries; use find_files to search the rest]"
            ]
        | otherwise = []
  pure $ T.intercalate "\n" (shown ++ note)

-- | Annotate a directory entry without following symlinks.
-- Symbolic links are tagged with '@' and never treated as directories,
-- even if they happen to point at one (possibly outside the sandbox).
annotateEntry :: FilePath -> FilePath -> IO Text
annotateEntry parent name = do
  let full = parent </> name
  link <- isSymlink full
  if link
    then pure $ T.pack name <> "@"
    else do
      isDir <- doesDirectoryExist full
      pure $
        if isDir
          then T.pack name <> "/"
          else T.pack name