diff --git a/registry/tools/web-search.json b/registry/tools/web-search.json new file mode 100644 index 00000000..bbaba024 --- /dev/null +++ b/registry/tools/web-search.json @@ -0,0 +1,31 @@ +{ + "name": "web-search", + "display_name": "Web Search", + "kind": "tool", + "version": "0.1.0", + "description": "Search the web using Brave Search API", + "keywords": ["search", "web", "brave", "internet"], + + "source": { + "dir": "tools-src/web-search", + "capabilities": "web-search-tool.capabilities.json", + "crate_name": "web-search-tool" + }, + + "artifacts": { + "wasm32-wasip2": { + "url": "https://github.com/nearai/ironclaw/releases/latest/download/web-search-wasm32-wasip2.tar.gz", + "sha256": null + } + }, + + "auth_summary": { + "method": "manual", + "provider": "Brave", + "secrets": ["brave_api_key"], + "shared_auth": null, + "setup_url": "https://brave.com/search/api/" + }, + + "tags": ["default", "search"] +} diff --git a/tools-src/web-search/Cargo.toml b/tools-src/web-search/Cargo.toml new file mode 100644 index 00000000..9473883f --- /dev/null +++ b/tools-src/web-search/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "web-search-tool" +version = "0.1.0" +edition = "2021" +description = "Brave Web Search tool for IronClaw (WASM component)" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +wit-bindgen = "0.41.0" + +[lib] +crate-type = ["cdylib"] + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 + + +[workspace] diff --git a/tools-src/web-search/src/lib.rs b/tools-src/web-search/src/lib.rs new file mode 100644 index 00000000..f42cf167 --- /dev/null +++ b/tools-src/web-search/src/lib.rs @@ -0,0 +1,478 @@ +//! Brave Web Search WASM Tool for IronClaw. +//! +//! Searches the web using the Brave Search API and returns structured results. +//! +//! # Authentication +//! +//! Store your Brave Search API key: +//! `ironclaw secret set brave_api_key ` +//! +//! Get a key at: https://brave.com/search/api/ + +wit_bindgen::generate!({ + world: "sandboxed-tool", + path: "../../wit/tool.wit", +}); + +use serde::Deserialize; + +const BRAVE_SEARCH_ENDPOINT: &str = "https://api.search.brave.com/res/v1/web/search"; +const MAX_COUNT: u32 = 20; +const DEFAULT_COUNT: u32 = 5; +const MAX_RETRIES: u32 = 3; + +struct WebSearchTool; + +impl exports::near::agent::tool::Guest for WebSearchTool { + fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response { + match execute_inner(&req.params) { + Ok(result) => exports::near::agent::tool::Response { + output: Some(result), + error: None, + }, + Err(e) => exports::near::agent::tool::Response { + output: None, + error: Some(e), + }, + } + } + + fn schema() -> String { + SCHEMA.to_string() + } + + fn description() -> String { + "Search the web using Brave Search. Returns titles, URLs, descriptions, and \ + publication dates for matching web pages. Supports filtering by country, \ + language, and freshness. Authentication is handled via the 'brave_api_key' \ + secret injected by the host." + .to_string() + } +} + +#[derive(Debug, Deserialize)] +struct SearchParams { + query: String, + count: Option, + country: Option, + search_lang: Option, + ui_lang: Option, + freshness: Option, +} + +#[derive(Debug, Deserialize)] +struct BraveSearchResponse { + web: Option, +} + +#[derive(Debug, Deserialize)] +struct BraveWebResults { + results: Option>, +} + +#[derive(Debug, Deserialize)] +struct BraveSearchResult { + title: Option, + url: Option, + description: Option, + age: Option, +} + +fn execute_inner(params: &str) -> Result { + let params: SearchParams = + serde_json::from_str(params).map_err(|e| format!("Invalid parameters: {e}"))?; + + if params.query.is_empty() { + return Err("'query' must not be empty".into()); + } + if params.query.len() > 2000 { + return Err("'query' exceeds maximum length of 2000 characters".into()); + } + + // Validate optional parameters. + if let Some(ref lang) = params.search_lang { + if !is_valid_lang_code(lang) { + return Err(format!( + "Invalid 'search_lang': expected 2-letter code like 'en', got '{lang}'" + )); + } + } + if let Some(ref country) = params.country { + if !is_valid_country_code(country) { + return Err(format!( + "Invalid 'country': expected 2-letter code like 'US', got '{country}'" + )); + } + } + if let Some(ref ui_lang) = params.ui_lang { + if !is_valid_ui_lang(ui_lang) { + return Err(format!( + "Invalid 'ui_lang': expected format like 'en-US', got '{ui_lang}'" + )); + } + } + if let Some(ref freshness) = params.freshness { + if !is_valid_freshness(freshness) { + return Err(format!( + "Invalid 'freshness': expected 'pd', 'pw', 'pm', 'py', or \ + 'YYYY-MM-DDtoYYYY-MM-DD', got '{freshness}'" + )); + } + } + + // Pre-flight: verify API key is available. + if !near::agent::host::secret_exists("brave_api_key") { + return Err( + "Brave API key not found in secret store. Set it with: \ + ironclaw secret set brave_api_key . \ + Get a key at: https://brave.com/search/api/" + .into(), + ); + } + + let count = params.count.unwrap_or(DEFAULT_COUNT).clamp(1, MAX_COUNT); + let url = build_search_url(¶ms.query, count, ¶ms); + + // X-Subscription-Token is injected by the host via credential config. + let headers = serde_json::json!({ + "Accept": "application/json", + "User-Agent": "IronClaw-WebSearch-Tool/0.1" + }); + + // Retry loop for transient errors (429 rate limit, 5xx server errors). + let response = { + let mut attempt = 0; + loop { + attempt += 1; + + let resp = + near::agent::host::http_request("GET", &url, &headers.to_string(), None, None) + .map_err(|e| format!("HTTP request failed: {e}"))?; + + if resp.status >= 200 && resp.status < 300 { + break resp; + } + + if attempt < MAX_RETRIES && (resp.status == 429 || resp.status >= 500) { + near::agent::host::log( + near::agent::host::LogLevel::Warn, + &format!( + "Brave API error {} (attempt {}/{}). Retrying...", + resp.status, attempt, MAX_RETRIES + ), + ); + continue; + } + + let body = String::from_utf8_lossy(&resp.body); + return Err(format!( + "Brave API error (HTTP {}): {}", + resp.status, body + )); + } + }; + + let body = + String::from_utf8(response.body).map_err(|e| format!("Invalid UTF-8 response: {e}"))?; + + let brave_response: BraveSearchResponse = + serde_json::from_str(&body).map_err(|e| format!("Failed to parse Brave response: {e}"))?; + + let results = brave_response + .web + .and_then(|w| w.results) + .unwrap_or_default(); + + let formatted: Vec = results + .into_iter() + .filter_map(|r| { + let title = r.title?; + let url = r.url?; + let description = r.description.unwrap_or_default(); + + let mut entry = serde_json::json!({ + "title": title, + "url": url, + "description": description, + }); + if let Some(age) = r.age { + entry["published"] = serde_json::json!(age); + } + // Extract hostname for site_name. + if let Some(host) = extract_hostname(&url) { + entry["site_name"] = serde_json::json!(host); + } + Some(entry) + }) + .collect(); + + let output = serde_json::json!({ + "query": params.query, + "result_count": formatted.len(), + "results": formatted, + }); + + serde_json::to_string(&output).map_err(|e| format!("Failed to serialize output: {e}")) +} + +fn build_search_url(query: &str, count: u32, params: &SearchParams) -> String { + let mut url = format!( + "{}?q={}&count={}", + BRAVE_SEARCH_ENDPOINT, + url_encode(query), + count + ); + + if let Some(ref country) = params.country { + url.push_str(&format!("&country={}", url_encode(country))); + } + if let Some(ref search_lang) = params.search_lang { + url.push_str(&format!("&search_lang={}", url_encode(search_lang))); + } + if let Some(ref ui_lang) = params.ui_lang { + url.push_str(&format!("&ui_lang={}", url_encode(ui_lang))); + } + if let Some(ref freshness) = params.freshness { + url.push_str(&format!("&freshness={}", url_encode(freshness))); + } + + url +} + +/// Percent-encode a string for safe use in URL query parameters. +fn url_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len() * 2); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char); + } + b' ' => out.push_str("%20"), + _ => { + out.push('%'); + out.push(char::from(b"0123456789ABCDEF"[(b >> 4) as usize])); + out.push(char::from(b"0123456789ABCDEF"[(b & 0xf) as usize])); + } + } + } + out +} + +/// Extract hostname from a URL string without a URL parser. +fn extract_hostname(url: &str) -> Option { + let after_scheme = url + .strip_prefix("https://") + .or_else(|| url.strip_prefix("http://"))?; + let host = after_scheme.split('/').next()?; + let host = host.split(':').next()?; // strip port + if host.is_empty() { + None + } else { + Some(host.to_string()) + } +} + +/// Validate a 2-letter language code (e.g. "en", "de"). +fn is_valid_lang_code(s: &str) -> bool { + s.len() == 2 && s.bytes().all(|b| b.is_ascii_lowercase()) +} + +/// Validate a 2-letter country code (e.g. "US", "DE"). +fn is_valid_country_code(s: &str) -> bool { + s.len() == 2 && s.bytes().all(|b| b.is_ascii_uppercase()) +} + +/// Validate a UI locale string (e.g. "en-US"). +fn is_valid_ui_lang(s: &str) -> bool { + let mut parts = s.split('-'); + if let (Some(lang), Some(country), None) = (parts.next(), parts.next(), parts.next()) { + is_valid_lang_code(lang) && is_valid_country_code(country) + } else { + false + } +} + +/// Validate a freshness filter value. +fn is_valid_freshness(s: &str) -> bool { + matches!(s, "pd" | "pw" | "pm" | "py") || is_valid_date_range(s) +} + +/// Check if the string is a valid date range like "2024-01-01to2024-12-31". +fn is_valid_date_range(s: &str) -> bool { + if let Some((start, end)) = s.split_once("to") { + is_date_like(start) && is_date_like(end) + } else { + false + } +} + +/// Basic check for YYYY-MM-DD format. +fn is_date_like(s: &str) -> bool { + s.len() == 10 + && s.as_bytes().get(4) == Some(&b'-') + && s.as_bytes().get(7) == Some(&b'-') + && s.bytes() + .enumerate() + .all(|(i, b)| i == 4 || i == 7 || b.is_ascii_digit()) +} + +const SCHEMA: &str = r#"{ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The search query to look up on the web" + }, + "count": { + "type": "integer", + "description": "Number of results to return (1-20, default 5)", + "minimum": 1, + "maximum": 20, + "default": 5 + }, + "country": { + "type": "string", + "description": "2-letter uppercase country code to bias results (e.g. 'US', 'DE', 'JP')" + }, + "search_lang": { + "type": "string", + "description": "2-letter lowercase language code for search results (e.g. 'en', 'de', 'fr')" + }, + "ui_lang": { + "type": "string", + "description": "Locale in language-region format (e.g. 'en-US', 'de-DE')" + }, + "freshness": { + "type": "string", + "description": "Filter by discovery time: 'pd' (past day), 'pw' (past week), 'pm' (past month), 'py' (past year), or date range 'YYYY-MM-DDtoYYYY-MM-DD'" + } + }, + "required": ["query"], + "additionalProperties": false +}"#; + +export!(WebSearchTool); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_url_encode() { + assert_eq!(url_encode("hello world"), "hello%20world"); + assert_eq!(url_encode("foo&bar=baz"), "foo%26bar%3Dbaz"); + assert_eq!(url_encode("simple"), "simple"); + } + + #[test] + fn test_extract_hostname() { + assert_eq!( + extract_hostname("https://example.com/path"), + Some("example.com".into()) + ); + assert_eq!( + extract_hostname("https://sub.example.com:8080/path"), + Some("sub.example.com".into()) + ); + assert_eq!( + extract_hostname("http://example.com"), + Some("example.com".into()) + ); + assert_eq!(extract_hostname("not-a-url"), None); + } + + #[test] + fn test_is_valid_lang_code() { + assert!(is_valid_lang_code("en")); + assert!(is_valid_lang_code("de")); + assert!(!is_valid_lang_code("EN")); // must be lowercase + assert!(!is_valid_lang_code("eng")); // too long + assert!(!is_valid_lang_code("")); // empty + } + + #[test] + fn test_is_valid_country_code() { + assert!(is_valid_country_code("US")); + assert!(is_valid_country_code("DE")); + assert!(!is_valid_country_code("us")); // must be uppercase + assert!(!is_valid_country_code("USA")); // too long + } + + #[test] + fn test_is_valid_ui_lang() { + assert!(is_valid_ui_lang("en-US")); + assert!(is_valid_ui_lang("de-DE")); + assert!(!is_valid_ui_lang("en")); + assert!(!is_valid_ui_lang("EN-US")); // lang part must be lowercase + assert!(!is_valid_ui_lang("en-us")); // country part must be uppercase + } + + #[test] + fn test_is_valid_freshness() { + assert!(is_valid_freshness("pd")); + assert!(is_valid_freshness("pw")); + assert!(is_valid_freshness("pm")); + assert!(is_valid_freshness("py")); + assert!(is_valid_freshness("2024-01-01to2024-12-31")); + assert!(!is_valid_freshness("invalid")); + assert!(!is_valid_freshness("2024-01-01")); // missing end date + } + + #[test] + fn test_is_date_like() { + assert!(is_date_like("2024-01-15")); + assert!(is_date_like("2025-12-31")); + assert!(!is_date_like("2024-1-15")); // not zero-padded + assert!(!is_date_like("24-01-15")); // short year + assert!(!is_date_like("")); // empty + } + + #[test] + fn test_build_search_url_minimal() { + let params = SearchParams { + query: "test query".to_string(), + count: None, + country: None, + search_lang: None, + ui_lang: None, + freshness: None, + }; + let url = build_search_url("test query", 5, ¶ms); + assert!(url.starts_with(BRAVE_SEARCH_ENDPOINT)); + assert!(url.contains("q=test%20query")); + assert!(url.contains("count=5")); + assert!(!url.contains("country=")); + } + + #[test] + fn test_build_search_url_full() { + let params = SearchParams { + query: "rust programming".to_string(), + count: Some(10), + country: Some("US".to_string()), + search_lang: Some("en".to_string()), + ui_lang: Some("en-US".to_string()), + freshness: Some("pw".to_string()), + }; + let url = build_search_url("rust programming", 10, ¶ms); + assert!(url.contains("q=rust%20programming")); + assert!(url.contains("count=10")); + assert!(url.contains("country=US")); + assert!(url.contains("search_lang=en")); + assert!(url.contains("ui_lang=en-US")); + assert!(url.contains("freshness=pw")); + } + + #[test] + fn test_url_encode_multibyte() { + assert_eq!(url_encode("café"), "caf%C3%A9"); + assert_eq!(url_encode("日本語"), "%E6%97%A5%E6%9C%AC%E8%AA%9E"); + } + + #[test] + fn test_extract_hostname_empty() { + assert_eq!(extract_hostname("https://"), None); + assert_eq!(extract_hostname("https:///path"), None); + assert_eq!(extract_hostname(""), None); + } +} diff --git a/tools-src/web-search/web-search-tool.capabilities.json b/tools-src/web-search/web-search-tool.capabilities.json new file mode 100644 index 00000000..56455114 --- /dev/null +++ b/tools-src/web-search/web-search-tool.capabilities.json @@ -0,0 +1,51 @@ +{ + "capabilities": { + "http": { + "allowlist": [ + { + "host": "api.search.brave.com", + "path_prefix": "/res/v1/web/search", + "methods": [ + "GET" + ] + } + ], + "credentials": { + "brave_api_key": { + "secret_name": "brave_api_key", + "location": { + "type": "header", + "name": "X-Subscription-Token" + }, + "host_patterns": [ + "api.search.brave.com" + ] + } + }, + "rate_limit": { + "requests_per_minute": 30, + "requests_per_hour": 500 + } + }, + "secrets": { + "allowed_names": [ + "brave_api_key" + ] + } + }, + "auth": { + "secret_name": "brave_api_key", + "display_name": "Brave Search", + "instructions": "Get a free API key at brave.com/search/api/ (Free tier: 2,000 queries/month)", + "setup_url": "https://brave.com/search/api/", + "env_var": "BRAVE_API_KEY" + }, + "setup": { + "required_secrets": [ + { + "name": "brave_api_key", + "prompt": "Brave Search API key (from brave.com/search/api)" + } + ] + } +}