mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-30 08:17:53 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8530f44630 | ||
|
|
5257fecca1 | ||
|
|
20073ccf57 | ||
|
|
1a26b1e57f |
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.13.1](https://github.com/nearai/ironclaw/compare/v0.13.0...v0.13.1) - 2026-03-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- add Brave Web Search WASM tool ([#474](https://github.com/nearai/ironclaw/pull/474))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- *(web)* auto-scroll and Enter key completion for slash command autocomplete ([#475](https://github.com/nearai/ironclaw/pull/475))
|
||||||
|
- correct download URLs for telegram-mtproto and slack-tool extensions ([#470](https://github.com/nearai/ironclaw/pull/470))
|
||||||
|
|
||||||
## [0.13.0](https://github.com/nearai/ironclaw/compare/v0.12.0...v0.13.0) - 2026-03-02
|
## [0.13.0](https://github.com/nearai/ironclaw/compare/v0.12.0...v0.13.0) - 2026-03-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
Generated
+1
-1
@@ -2828,7 +2828,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ironclaw"
|
name = "ironclaw"
|
||||||
version = "0.13.0"
|
version = "0.13.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ exclude = [
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "ironclaw"
|
name = "ironclaw"
|
||||||
version = "0.13.0"
|
version = "0.13.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.92"
|
rust-version = "1.92"
|
||||||
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-tool-wasm32-wasip2.tar.gz",
|
||||||
"sha256": null
|
"sha256": null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-mtproto-wasm32-wasip2.tar.gz",
|
||||||
"sha256": null
|
"sha256": null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -359,6 +359,9 @@ function selectSlashItem(cmd) {
|
|||||||
function updateSlashHighlight() {
|
function updateSlashHighlight() {
|
||||||
const items = document.querySelectorAll('#slash-autocomplete .slash-ac-item');
|
const items = document.querySelectorAll('#slash-autocomplete .slash-ac-item');
|
||||||
items.forEach((el, i) => el.classList.toggle('selected', i === _slashSelected));
|
items.forEach((el, i) => el.classList.toggle('selected', i === _slashSelected));
|
||||||
|
if (_slashSelected >= 0 && items[_slashSelected]) {
|
||||||
|
items[_slashSelected].scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterSlashCommands(value) {
|
function filterSlashCommands(value) {
|
||||||
@@ -1196,7 +1199,7 @@ chatInput.addEventListener('keydown', (e) => {
|
|||||||
updateSlashHighlight();
|
updateSlashHighlight();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === 'Tab' || (e.key === 'Enter' && _slashSelected >= 0)) {
|
if (e.key === 'Tab' || e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const pick = _slashSelected >= 0 ? _slashMatches[_slashSelected] : _slashMatches[0];
|
const pick = _slashSelected >= 0 ? _slashMatches[_slashSelected] : _slashMatches[0];
|
||||||
if (pick) selectSlashItem(pick.cmd);
|
if (pick) selectSlashItem(pick.cmd);
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -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 <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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user