mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
* feat: add Brave Web Search WASM tool Add a new WASM tool for searching the web via the Brave Search API. Follows the same architecture as the GitHub WASM tool with zero-exposure credential injection (X-Subscription-Token header). Features: - Full Brave Search API support (query, count, country, search_lang, ui_lang, freshness) - Input validation on all parameters - Retry logic for 429/5xx transient errors - RFC 3986 percent-encoding - Registry manifest for Extensions tab discovery Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: avoid Vec allocation in is_valid_ui_lang Use iterator-based destructuring instead of collecting into a Vec, avoiding a heap allocation in the WASM sandbox. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
479 lines
15 KiB
Rust
479 lines
15 KiB
Rust
//! 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 <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<u32>,
|
|
country: Option<String>,
|
|
search_lang: Option<String>,
|
|
ui_lang: Option<String>,
|
|
freshness: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct BraveSearchResponse {
|
|
web: Option<BraveWebResults>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct BraveWebResults {
|
|
results: Option<Vec<BraveSearchResult>>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct BraveSearchResult {
|
|
title: Option<String>,
|
|
url: Option<String>,
|
|
description: Option<String>,
|
|
age: Option<String>,
|
|
}
|
|
|
|
fn execute_inner(params: &str) -> Result<String, String> {
|
|
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 <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<serde_json::Value> = 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<String> {
|
|
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);
|
|
}
|
|
}
|