packages feed

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

module LLM.Tools.MultiReplaceInFile (multiReplaceInFileToolTyped, MultiReplaceInFileToolArgs (..)) 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 Replacement = Replacement
  { _repOld :: Text,
    _repNew :: Text
  }
  deriving (Generic)
  deriving (FromJSON) via (AC.Autodocodec Replacement)

instance AC.HasCodec Replacement where
  codec :: AC.JSONCodec Replacement
  codec =
    AC.object "Replacement" $
      Replacement
        <$> AC.requiredField "old" "The text content to be replaced" AC..= (._repOld)
        <*> AC.requiredField "new" "The replacement text content" AC..= (._repNew)

data MultiReplaceInFileToolArgs = MultiReplaceInFileToolArgs
  { _mrifPath :: Text,
    _mrifReplacements :: [Replacement]
  }
  deriving (Generic)
  deriving (FromJSON) via (AC.Autodocodec MultiReplaceInFileToolArgs)

instance AC.HasCodec MultiReplaceInFileToolArgs where
  codec :: AC.JSONCodec MultiReplaceInFileToolArgs
  codec =
    AC.object "apply multiple replacements to a file" $
      MultiReplaceInFileToolArgs
        <$> AC.requiredField "path" "Relative file path to edit" AC..= (._mrifPath)
        <*> AC.requiredField "replacements" "List of replacements to apply in order" AC..= (._mrifReplacements)

multiReplaceInFileToolTyped :: FsConfig -> TypedTool ctx MultiReplaceInFileToolArgs
multiReplaceInFileToolTyped cfg =
  TypedTool
    { ttoolName = "multi_replace_in_file",
      ttoolDescription =
        "Apply multiple text replacements to a file in order. "
          <> "Each 'old' string must appear exactly once in the file at the time it is applied. "
          <> "Returns an error if any 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 -> MultiReplaceInFileToolArgs -> IO Text
replaceExecTyped cfg args = do
  let MultiReplaceInFileToolArgs {_mrifPath, _mrifReplacements} = args
  resolved <- sandboxPath cfg (T.unpack _mrifPath)
  contentE <- readBoundedTextFile resolved _mrifPath maxReadBytes
  case contentE of
    Left err -> pure $ "Error: " <> err
    Right content -> case applyReplacements _mrifReplacements content of
      Left err -> pure err
      Right result -> do
        TIO.writeFile resolved result
        pure $
          "Successfully applied "
            <> T.pack (show (length _mrifReplacements))
            <> " replacement(s) in "
            <> _mrifPath

-- | Apply a list of replacements sequentially, failing on the first error.
applyReplacements :: [Replacement] -> Text -> Either Text Text
applyReplacements [] content = Right content
applyReplacements (Replacement {_repOld, _repNew} : rest) content =
  case countOccurrences _repOld content of
    0 -> Left $ "Error: 'old' string not found in file: " <> _repOld
    1 -> applyReplacements rest (replaceFirst _repOld _repNew content)
    n ->
      Left $
        "Error: 'old' string found "
          <> T.pack (show n)
          <> " times (must appear exactly once): "
          <> _repOld

-- | 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