tool: add web_search via Brave Search API

ober

806d510435f929c7baaffec3efa6a5cdca58fc09

diff --git a/src/jcode/tool/web.ss b/src/jcode/tool/web.ss
index 3c13e5d..e4f2d22 100644
--- a/src/jcode/tool/web.ss
+++ b/src/jcode/tool/web.ss
@@ -4,7 +4,9 @@
 
 (import :std/net/request
         :std/text/json
+        :std/misc/string
         :jcode/core/log
+        :jcode/core/config
         :jcode/tool/registry)
 
 (def logger (make-logger "tool.web"))
@@ -13,7 +15,11 @@
   (register-tool! "fetch"
     "Fetch a URL via HTTP/HTTPS. Supports GET and POST. Returns HTTP status and response body."
     (make-fetch-schema)
-    handle-fetch))
+    handle-fetch)
+  (register-tool! "web_search"
+    "Search the web via Brave Search. Returns a ranked list of {title, url, snippet}. Use this when you need fresh information or do not know the URL; follow up with fetch on a specific result URL."
+    (make-search-schema)
+    handle-search))
 
 (def (make-fetch-schema)
   (let ((schema (make-hash-table))
@@ -69,3 +75,98 @@
          (text   (request-text   resp)))
     (request-close resp)
     (format "Status: ~a\n~a" status text)))
+
+;; ---- web_search (Brave Search API) ----
+
+(def (make-search-schema)
+  (let ((schema (make-hash-table))
+        (props  (make-hash-table)))
+    (hash-put! schema "type" "object")
+    (hash-put! schema "required" '("query"))
+    (let ((q-p (make-hash-table)))
+      (hash-put! q-p "type" "string")
+      (hash-put! q-p "description" "Search query.")
+      (hash-put! props "query" q-p))
+    (let ((c-p (make-hash-table)))
+      (hash-put! c-p "type" "integer")
+      (hash-put! c-p "description" "Number of results to return (1-20, default 5).")
+      (hash-put! props "count" c-p))
+    (hash-put! schema "properties" props)
+    schema))
+
+(def (brave-api-key)
+  (or (getenv "BRAVE_API_KEY")
+      (config-ref "brave_api_key")
+      (config-ref "providers" "brave" "api_key")))
+
+(def (handle-search args)
+  (let* ((query (hash-ref args "query" #f))
+         (count (let ((c (hash-ref args "count" #f)))
+                  (cond
+                    ((or (not c) (eq? c (void))) 5)
+                    ((number? c) (max 1 (min 20 (exact c))))
+                    ((string? c) (max 1 (min 20 (string->number c))))
+                    (else 5))))
+         (key   (brave-api-key)))
+    (unless query (error 'web_search "Missing required parameter: query"))
+    (unless key
+      (error 'web_search
+        "No Brave Search API key. Set BRAVE_API_KEY env var or add 'brave_api_key' to jcode.json."))
+    (log-info logger "web_search" `((q . ,query) (count . ,count)))
+    (try
+      (brave-search query count key)
+      (catch (e)
+        (format "Error: ~a" (err->string e))))))
+
+(def (brave-search query count key)
+  (let* ((url (string-append
+                "https://api.search.brave.com/res/v1/web/search?q="
+                (url-encode query)
+                "&count=" (number->string count)))
+         (headers `(("X-Subscription-Token" . ,key)
+                    ("Accept" . "application/json")))
+         (resp   (http-get url headers #f))
+         (status (request-status resp))
+         (text   (request-text  resp)))
+    (request-close resp)
+    (cond
+      ((not (= status 200))
+       (format "Brave Search HTTP ~a\n~a" status text))
+      (else (format-brave-results query text)))))
+
+(def (format-brave-results query body)
+  (let* ((root    (string->json-object body))
+         (web     (and (hash-table? root) (hash-get root "web")))
+         (results (and (hash-table? web)  (hash-get web  "results"))))
+    (cond
+      ((or (not results) (null? results))
+       (format "No results for: ~a" query))
+      (else
+       (let loop ((rs results) (i 1) (acc '()))
+         (cond
+           ((null? rs)
+            (string-append
+              (format "Brave Search: ~a (~a results)\n\n"
+                query (length results))
+              (string-join (reverse acc) "\n\n")))
+           (else
+            (let* ((r     (car rs))
+                   (title (or (hash-get r "title") ""))
+                   (url   (or (hash-get r "url") ""))
+                   (snip  (or (hash-get r "description") "")))
+              (loop (cdr rs) (+ i 1)
+                (cons (format "~a. ~a\n   ~a\n   ~a"
+                        i title url (strip-tags snip))
+                      acc))))))))))
+
+(def (strip-tags s)
+  ;; Brave snippets contain <strong>...</strong> markup; flatten for plain text.
+  (let loop ((chars (string->list s)) (out '()) (in-tag? #f))
+    (cond
+      ((null? chars) (list->string (reverse out)))
+      ((and (not in-tag?) (char=? (car chars) #\<))
+       (loop (cdr chars) out #t))
+      ((and in-tag? (char=? (car chars) #\>))
+       (loop (cdr chars) out #f))
+      (in-tag? (loop (cdr chars) out #t))
+      (else    (loop (cdr chars) (cons (car chars) out) #f)))))