packages feed

praxo-core-0.3.0: src/Praxo/ToolCall.hs

module Praxo.ToolCall (ToolCall(..), parseInputJson, parseToolCall, stripToolCallMarkup) where

import Data.Char (isAlpha, isAlphaNum, isAscii, isSpace)
import Data.List (findIndex, isPrefixOf, isSuffixOf, stripPrefix, tails)
import Data.Maybe (mapMaybe)
import Praxo.Json

data ToolCall = ToolCall { callName :: String, callInput :: Json, callMetadata :: [(String, String)] } deriving (Eq, Show)

parseInputJson :: String -> Maybe Json
parseInputJson text = case parseJson (trim text) of
    Right value@(JObject _) -> Just value
    _ -> Nothing

parseToolCall :: String -> Maybe ToolCall
parseToolCall content | length content > 262144 = Nothing
parseToolCall content = case parseJson content of
    Right object@(JObject _) | not (null (asString (field "name" object))) -> do
        let arguments = field "input" object
            metadataKeys = ["operation", "resource", "risk", "reason", "idempotency_key"]
            stringField key = case field key object of JString _ -> True; JNull -> True; _ -> False
        if not (all stringField metadataKeys) then Nothing else case arguments of
            JObject _ -> fromObject object arguments
            JNull -> fromObject object JNull
            _ -> Nothing
    _ -> case reverse (blocks content) of
        block:_ -> blockCall block
        [] -> case reverse (mapMaybe plain (lines content)) of
            candidate:_ -> blockCall candidate
            [] -> Nothing
  where
    fromObject object arguments = Just (ToolCall (asString (field "name" object)) arguments
        [(key, asString (field key object)) | key <- ["operation", "resource", "risk", "reason", "idempotency_key"]])
    blockCall (name, body) = do
        arguments <- parseInput body
        pure (ToolCall name arguments [])
    plain line =
        let (name, body) = break isSpace (trim line)
        in if validName name && '.' `elem` name && "{" `isPrefixOf` trim body && "}" `isSuffixOf` trim body then
            Just (name, body)
        else Nothing

parseInput :: String -> Maybe Json
parseInput raw = case parseJson (trim raw) of
    Right value@(JObject _) -> Just value
    _ -> case parseJson (concatMap (\c -> if c == '\\' then "\\\\" else [c]) (trim raw)) of
        Right value@(JObject _) -> Just value
        _ -> Nothing

trim :: String -> String
trim = reverse . dropWhile isSpace . reverse . dropWhile isSpace

validName :: String -> Bool
validName (first:rest) = isAscii first && isAlpha first && all (\c -> isAscii c && (isAlphaNum c || c `elem` "_.-")) rest
validName [] = False

splitAtText :: String -> String -> Maybe (String, String)
splitAtText needle haystack = do
    index <- findIndex (isPrefixOf needle) (tails haystack)
    pure (take index haystack, drop (index + length needle) haystack)

blocks :: String -> [(String, String)]
blocks content = case break (== '<') content of
    (_, []) -> []
    (_, _:rest) -> case splitAtText ">" rest of
        Nothing -> []
        Just (header, body) -> case headerName header of
            Nothing -> blocks body
            Just name -> case splitAtText ("</" ++ takeWhile (not . isSpace) header ++ ">") body of
                Nothing -> []
                Just (arguments, suffix) -> (name, arguments) : blocks suffix

headerName :: String -> Maybe String
headerName header
    | "tool_call" `isPrefixOf` header = do
        remainder <- stripPrefix "tool_call" header
        if null remainder || not (isSpace (firstCharacter remainder)) then Nothing else do
            named <- stripPrefix "name=\"" (dropWhile isSpace remainder)
            let (name, suffix) = break (== '"') named
            case suffix of
                '"':after | not (null name) && all isSpace after -> Just name
                _ -> Nothing
    | validName header && '.' `elem` header && not (".." `isInfix` header) && not ("." `isSuffixOf` header) = Just header
    | otherwise = Nothing
  where
    firstCharacter (first:_) = first
    firstCharacter [] = 'x'
    isInfix needle haystack = any (isPrefixOf needle) (tails haystack)

stripToolCallMarkup :: String -> String
stripToolCallMarkup content | length content > 262144 = content
stripToolCallMarkup content = trim (collapse (strip content))
  where
    strip [] = []
    strip text@('<':rest) = case splitAtText ">" rest of
        Just (header, body)
            | Just _ <- headerName header ->
                let closing = "</" ++ takeWhile (not . isSpace) header ++ ">"
                in case splitAtText closing body of
                    Just (_, suffix) -> strip suffix
                    Nothing -> ""
        Just (header, body) -> '<' : header ++ ">" ++ strip body
        Nothing -> text
    strip (first:rest) = first : strip rest
    collapse ('\n':'\n':'\n':rest) = collapse ('\n':'\n':rest)
    collapse (first:rest) = first : collapse rest
    collapse [] = []