packages feed

llm-simple-0.1.0.1: src/LLM/Tools/ReplaceInFile.hs

module LLM.Tools.ReplaceInFile (replaceInFileToolTyped, ReplaceInFileToolArgs (..)) where

import Autodocodec qualified as AC
import Data.Aeson (FromJSON)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import GHC.Generics (Generic)
import LLM.Core.Types (TypedTool (..))
import LLM.Tools.FsConfig (FsConfig, sandboxPath)
import LLM.Tools.FsLimits (maxReadBytes, readBoundedTextFile)

data ReplaceInFileToolArgs = ReplaceInFileToolArgs
  { _rifPath :: Text,
    _rifOld :: Text,
    _rifNew :: Text
  }
  deriving (Generic)
  deriving (FromJSON) via (AC.Autodocodec ReplaceInFileToolArgs)

instance AC.HasCodec ReplaceInFileToolArgs where
  codec :: AC.JSONCodec ReplaceInFileToolArgs
  codec =
    AC.object "replace a string in a file" $
      ReplaceInFileToolArgs
        <$> AC.requiredField "path" "Relative file path to write to" AC..= (._rifPath)
        <*> AC.requiredField "old" "The text content to be replaced in the file" AC..= (._rifOld)
        <*> AC.requiredField "new" "The replacement text content to write to the file" AC..= (._rifNew)

replaceInFileToolTyped :: FsConfig -> TypedTool ctx ReplaceInFileToolArgs
replaceInFileToolTyped cfg =
  TypedTool
    { ttoolName = "replace_in_file",
      ttoolDescription =
        "Replace the first occurrence of a string in a file. "
          <> "The 'old' string must appear exactly once in the file. "
          <> "Returns an error if the string is not found or appears more than once. "
          <> "Files larger than "
          <> T.pack (show maxReadBytes)
          <> " bytes are refused.",
      ttoolReadonly = False,
      ttoolExecute = const (replaceExecTyped cfg)
    }

replaceExecTyped :: FsConfig -> ReplaceInFileToolArgs -> IO Text
replaceExecTyped cfg args = do
  let ReplaceInFileToolArgs {_rifPath, _rifOld, _rifNew} = args
      p = _rifPath
  resolved <- sandboxPath cfg (T.unpack p)
  contentE <- readBoundedTextFile resolved p maxReadBytes
  case contentE of
    Left err -> pure $ "Error: " <> err
    Right content -> case countOccurrences _rifOld content of
      0 -> pure "Error: the 'old' string was not found in the file"
      1 -> do
        let replaced = replaceFirst _rifOld _rifNew content
        TIO.writeFile resolved replaced
        pure $ "Successfully replaced text in " <> p
      n ->
        pure $
          "Error: the 'old' string was found "
            <> T.pack (show n)
            <> " times; it must appear exactly once"

-- | Count non-overlapping occurrences of needle in haystack.
countOccurrences :: Text -> Text -> Int
countOccurrences needle haystack
  | T.null needle = 0
  | otherwise = go 0 haystack
  where
    go !n hay = case T.breakOn needle hay of
      (_, rest)
        | T.null rest -> n
        | otherwise -> go (n + 1) (T.drop (T.length needle) rest)

-- | Replace the first occurrence of needle with replacement.
replaceFirst :: Text -> Text -> Text -> Text
replaceFirst needle replacement haystack =
  let (before, after) = T.breakOn needle haystack
   in before <> replacement <> T.drop (T.length needle) after