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