packages feed

llm-simple-0.4.2.0: 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 GHC.Generics (Generic)
import LLM.Core.Types (TypedTool (..))
import LLM.Tools.FsConfig (FsConfig, sandboxPath, withWorkspacePaths)
import LLM.Tools.FsLimits (maxReadBytes, readBoundedUtf8File, writeUtf8File)
import LLM.Tools.Internal.PathLock (withPathLock)
import LLM.Tools.Internal.TextEdit (countOccurrences, replaceFirst)

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 (withWorkspacePaths cfg . replaceExecTyped cfg)
    }

replaceExecTyped :: FsConfig -> MultiReplaceInFileToolArgs -> IO Text
replaceExecTyped cfg MultiReplaceInFileToolArgs {_mrifPath, _mrifReplacements} = do
  resolved <- sandboxPath cfg (T.unpack _mrifPath)
  -- Hold the lock from the read to the write, so a concurrent edit of the
  -- same file cannot be lost.
  withPathLock resolved $ do
    contentE <- readBoundedUtf8File 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
          writeUtf8File 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