never executed always true always false
    1 module PureClaw.Tools.WebSearch
    2   ( -- * Tool registration
    3     webSearchTool
    4     -- * Exported for testing
    5   , formatResults
    6   , escapeQuery
    7   ) where
    8 
    9 import Control.Exception
   10 import Data.Aeson
   11 import Data.Aeson.Types
   12 import Data.ByteString (ByteString)
   13 import Data.Text (Text)
   14 import Data.Text qualified as T
   15 import Network.HTTP.Types.Header qualified as Header
   16 
   17 import PureClaw.Core.Types
   18 import PureClaw.Handles.Network
   19 import PureClaw.Providers.Class
   20 import PureClaw.Security.Secrets
   21 import PureClaw.Tools.Registry
   22 
   23 -- | Create a web search tool using the Brave Search API.
   24 -- The API key is accessed via CPS to prevent leakage.
   25 webSearchTool :: AllowList Text -> ApiKey -> NetworkHandle -> (ToolDefinition, ToolHandler)
   26 webSearchTool allowList apiKey nh = (def, handler)
   27   where
   28     def = ToolDefinition
   29       { _td_name        = "web_search"
   30       , _td_description = "Search the web using Brave Search. Returns titles, URLs, and descriptions."
   31       , _td_inputSchema = object
   32           [ "type" .= ("object" :: Text)
   33           , "properties" .= object
   34               [ "query" .= object
   35                   [ "type" .= ("string" :: Text)
   36                   , "description" .= ("The search query" :: Text)
   37                   ]
   38               , "count" .= object
   39                   [ "type" .= ("integer" :: Text)
   40                   , "description" .= ("Number of results (default 5, max 20)" :: Text)
   41                   ]
   42               ]
   43           , "required" .= (["query"] :: [Text])
   44           ]
   45       }
   46 
   47     handler = ToolHandler $ \input ->
   48       case parseEither parseInput input of
   49         Left err -> pure (T.pack err, True)
   50         Right (query, count) -> do
   51           let url = "https://api.search.brave.com/res/v1/web/search?q="
   52                     <> T.pack (escapeQuery (T.unpack query))
   53                     <> "&count=" <> T.pack (show (min 20 (max 1 count)))
   54           case mkAllowedUrl allowList url of
   55             Left (UrlNotAllowed _) ->
   56               pure ("Search API domain not in allow-list", True)
   57             Left (UrlMalformed u) ->
   58               pure ("Malformed search URL: " <> u, True)
   59             Right allowed -> do
   60               let headers = withApiKey apiKey $ \key ->
   61                     [ ( Header.hAccept, "application/json" )
   62                     , ( "X-Subscription-Token", key )
   63                     ]
   64               result <- try @SomeException
   65                 (_nh_httpGetWithHeaders nh allowed headers)
   66               case result of
   67                 Left e -> pure (T.pack (show e), True)
   68                 Right resp
   69                   | _hr_statusCode resp /= 200 ->
   70                       pure ("Search API error: HTTP "
   71                             <> T.pack (show (_hr_statusCode resp)), True)
   72                   | otherwise ->
   73                       pure (formatResults (_hr_body resp), False)
   74 
   75     parseInput :: Value -> Parser (Text, Int)
   76     parseInput = withObject "WebSearchInput" $ \o ->
   77       (,) <$> o .: "query" <*> o .:? "count" .!= 5
   78 
   79 -- | URL-encode a query string (minimal: spaces and special chars).
   80 escapeQuery :: String -> String
   81 escapeQuery = concatMap escapeChar
   82   where
   83     escapeChar ' ' = "+"
   84     escapeChar '&' = "%26"
   85     escapeChar '=' = "%3D"
   86     escapeChar '+' = "%2B"
   87     escapeChar '#' = "%23"
   88     escapeChar '%' = "%25"
   89     escapeChar c   = [c]
   90 
   91 -- | Parse Brave Search JSON response and format as readable text.
   92 formatResults :: ByteString -> Text
   93 formatResults body =
   94   case eitherDecodeStrict body of
   95     Left err -> "Failed to parse search results: " <> T.pack err
   96     Right val ->
   97       case parseMaybe parseWebResults val of
   98         Nothing      -> "No results found"
   99         Just []      -> "No results found"
  100         Just results -> T.intercalate "\n\n" results
  101 
  102 -- | Parse the web.results array from a Brave Search response.
  103 parseWebResults :: Value -> Parser [Text]
  104 parseWebResults = withObject "BraveResponse" $ \o -> do
  105   web <- o .: "web"
  106   results <- web .: "results"
  107   mapM parseResult results
  108 
  109 -- | Parse a single search result into formatted text.
  110 parseResult :: Value -> Parser Text
  111 parseResult = withObject "SearchResult" $ \o -> do
  112   title <- o .:? "title" .!= ""
  113   url   <- o .:? "url"   .!= ""
  114   desc  <- o .:? "description" .!= ""
  115   pure $ title <> "\n" <> url <> "\n" <> desc