packages feed

louter-0.1.0.0: src/Louter/Streaming/XMLToolCallParser.hs

{-# LANGUAGE OverloadedStrings #-}

-- | XML Tool Call Parser for Qwen3-Coder format
--
-- Parses XML-formatted tool calls like:
-- @
-- <tool_call>
--   <function=WriteFile>
--     <parameter=file_path>test.txt</parameter>
--     <parameter=content>Hello World!</parameter>
--   </function>
-- </tool_call>
-- @
--
-- Converts to OpenAI ToolCall format for uniform handling
module Louter.Streaming.XMLToolCallParser
  ( parseXMLToolCalls
  , extractFunctionName
  , extractParameters
  , stripXMLToolCallTags
  , convertToToolCall
  ) where

import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.HashMap.Strict as HM
import Data.Aeson (Value(..), decode, object, (.=))
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteString.Lazy as BL
import qualified Data.ByteString.Lazy.Char8 as BLC
import Text.Regex.TDFA ((=~))
import Data.Maybe (mapMaybe, fromMaybe)
import Louter.Types.Streaming (ToolCall(..))

-- | Parse all XML tool calls from text content
-- Returns list of (function_name, parameters_map)
parseXMLToolCalls :: Text -> [(Text, HM.HashMap Text Value)]
parseXMLToolCalls content =
  let toolCallBlocks = extractToolCallBlocks content
  in mapMaybe parseToolCallBlock toolCallBlocks

-- | Extract all <tool_call>...</tool_call> blocks from text
-- Uses manual splitting to avoid regex complexity with nested tags
extractToolCallBlocks :: Text -> [Text]
extractToolCallBlocks content = extractBlocks content []
  where
    extractBlocks :: Text -> [Text] -> [Text]
    extractBlocks text acc
      | T.null text = reverse acc
      | otherwise =
          case T.breakOn "<tool_call>" text of
            (_, rest) | T.null rest -> reverse acc
            (_, rest) ->
              let afterOpen = T.drop (T.length "<tool_call>") rest
              in case T.breakOn "</tool_call>" afterOpen of
                   (block, afterClose) | T.null afterClose -> reverse acc
                   (block, afterClose) ->
                     let remaining = T.drop (T.length "</tool_call>") afterClose
                     in extractBlocks remaining (block : acc)

-- | Parse a single tool call block
parseToolCallBlock :: Text -> Maybe (Text, HM.HashMap Text Value)
parseToolCallBlock block = do
  functionName <- extractFunctionName block
  let parameters = extractParameters block
  return (functionName, parameters)

-- | Extract function name from <function=NAME> tag
extractFunctionName :: Text -> Maybe Text
extractFunctionName block =
  let pattern = "<function=([^>]+)>" :: String
      matches = T.unpack block =~ pattern :: [[String]]
  in case matches of
       ((_ : name : _) : _) -> Just (T.pack name)
       _ -> Nothing

-- | Extract all parameters from <parameter=key>value</parameter> tags
extractParameters :: Text -> HM.HashMap Text Value
extractParameters block =
  let pattern = "<parameter=([^>]+)>([^<]*)</parameter>" :: String
      matches = T.unpack block =~ pattern :: [[String]]
      pairs = [(T.pack key, parseValue (T.pack value)) | (_ : key : value : _) <- matches]
  in HM.fromList pairs

-- | Parse parameter value with type detection
-- Attempts to parse as JSON first (for numbers, booleans, objects)
-- Falls back to string if JSON parsing fails
parseValue :: Text -> Value
parseValue text =
  let trimmed = T.strip text
      -- Try parsing as JSON
      jsonResult = decode (BL.fromStrict $ TE.encodeUtf8 trimmed) :: Maybe Value
  in case jsonResult of
       Just val -> val
       Nothing  -> String trimmed  -- Fallback to string

-- | Remove all <tool_call>...</tool_call> tags from text
-- Keeps surrounding text content intact
stripXMLToolCallTags :: Text -> Text
stripXMLToolCallTags content = T.strip $ removeBlocks content
  where
    removeBlocks :: Text -> Text
    removeBlocks text =
      case T.breakOn "<tool_call>" text of
        (before, rest) | T.null rest -> before
        (before, rest) ->
          case T.breakOn "</tool_call>" (T.drop (T.length "<tool_call>") rest) of
            (_, afterClose) | T.null afterClose -> before
            (_, afterClose) ->
              let remaining = T.drop (T.length "</tool_call>") afterClose
              in before <> " " <> removeBlocks remaining

-- | Convert parsed XML tool call to ToolCall format
-- Uses the Louter.Types.Streaming.ToolCall structure
convertToToolCall :: Int -> (Text, HM.HashMap Text Value) -> ToolCall
convertToToolCall index (functionName, parameters) =
  ToolCall
    { toolCallId = "call_" <> T.pack (show index)
    , toolCallName = functionName
    , toolCallArguments = Object $ KM.fromHashMapText parameters
    }