use async_trait::async_trait;
use reqwest::Client;
use serde_json::{json, Value};
use crate::config::{SearchBackendKind, SearchConfig};
use crate::traits::{Tool, ToolCapabilities};
use super::web_fetch::build_browser_client;
const DEFAULT_MAX_RESULTS: usize = 5;
const MAX_MAX_RESULTS: usize = 10;
pub struct SearchResult {
pub title: String,
pub url: String,
pub snippet: String,
}
#[async_trait]
pub trait SearchBackend: Send + Sync {
async fn search(&self, query: &str, max_results: usize) -> anyhow::Result<Vec<SearchResult>>;
}
pub struct DuckDuckGoBackend {
client: Client,
}
impl DuckDuckGoBackend {
pub fn new() -> Self {
Self {
client: build_browser_client(),
}
}
}
#[async_trait]
impl SearchBackend for DuckDuckGoBackend {
async fn search(&self, query: &str, max_results: usize) -> anyhow::Result<Vec<SearchResult>> {
let url =
reqwest::Url::parse_with_params("https://lite.duckduckgo.com/lite/", &[("q", query)])?
.to_string();
let resp = self.client.get(&url).send().await?;
let html = resp.text().await?;
let mut results = Vec::new();
let mut pos = 0;
while results.len() < max_results {
let link_start = match html[pos..].find("class=\"result-link\"") {
Some(p) => pos + p,
None => break,
};
let tag_start = html[..link_start].rfind("<a ").unwrap_or(link_start);
let href = extract_attr(&html[tag_start..], "href").unwrap_or_default();
let title_start = match html[link_start..].find('>') {
Some(p) => link_start + p + 1,
None => {
pos = link_start + 20;
continue;
}
};
let title_end = match html[title_start..].find("</a>") {
Some(p) => title_start + p,
None => {
pos = title_start;
continue;
}
};
let title = strip_tags(&html[title_start..title_end]);
let snippet = if let Some(sn_pos) = html[title_end..].find("class=\"result-snippet\"") {
let sn_start = title_end + sn_pos;
let sn_content_start = match html[sn_start..].find('>') {
Some(p) => sn_start + p + 1,
None => sn_start,
};
let sn_end = match html[sn_content_start..].find("</td>") {
Some(p) => sn_content_start + p,
None => sn_content_start,
};
strip_tags(&html[sn_content_start..sn_end])
.trim()
.to_string()
} else {
String::new()
};
if !href.is_empty() && !title.is_empty() {
results.push(SearchResult {
title,
url: href,
snippet,
});
}
pos = title_end + 1;
}
Ok(results)
}
}
fn extract_attr(tag: &str, attr: &str) -> Option<String> {
let pattern = format!("{}=\"", attr);
let start = tag.find(&pattern)? + pattern.len();
let end = tag[start..].find('"')? + start;
Some(html_decode(&tag[start..end]))
}
fn strip_tags(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut in_tag = false;
for ch in s.chars() {
match ch {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => out.push(ch),
_ => {}
}
}
html_decode(&out)
}
fn html_decode(s: &str) -> String {
s.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("'", "'")
.replace(" ", " ")
}
pub struct BraveBackend {
client: Client,
api_key: String,
}
impl BraveBackend {
pub fn new(api_key: &str) -> Self {
Self {
client: Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("failed to build HTTP client"),
api_key: api_key.to_string(),
}
}
}
#[async_trait]
impl SearchBackend for BraveBackend {
async fn search(&self, query: &str, max_results: usize) -> anyhow::Result<Vec<SearchResult>> {
let url = reqwest::Url::parse_with_params(
"https://api.search.brave.com/res/v1/web/search",
&[("q", query), ("count", &max_results.to_string())],
)?
.to_string();
let max_retries = 3;
let mut last_status = reqwest::StatusCode::OK;
for attempt in 0..max_retries {
let resp = self
.client
.get(&url)
.header("X-Subscription-Token", &self.api_key)
.header("Accept", "application/json")
.send()
.await?;
last_status = resp.status();
if resp.status().is_success() {
let data: Value = resp.json().await?;
let empty = vec![];
let web_results = data["web"]["results"].as_array().unwrap_or(&empty);
let results = web_results
.iter()
.take(max_results)
.filter_map(|r| {
Some(SearchResult {
title: r["title"].as_str()?.to_string(),
url: r["url"].as_str()?.to_string(),
snippet: r["description"].as_str().unwrap_or("").to_string(),
})
})
.collect();
return Ok(results);
}
if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS && attempt < max_retries - 1
{
let delay_secs = 2u64.pow(attempt as u32); tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;
continue;
}
break;
}
anyhow::bail!("Brave search API error: HTTP {}", last_status)
}
}
pub struct WebSearchTool {
backend: Box<dyn SearchBackend>,
}
impl WebSearchTool {
pub fn new(config: &SearchConfig) -> Self {
let backend: Box<dyn SearchBackend> = match config.backend {
SearchBackendKind::Brave => Box::new(BraveBackend::new(&config.api_key)),
SearchBackendKind::DuckDuckGo => Box::new(DuckDuckGoBackend::new()),
};
Self { backend }
}
}
#[async_trait]
impl Tool for WebSearchTool {
fn name(&self) -> &str {
"web_search"
}
fn description(&self) -> &str {
"Search the web and return titles, URLs, and snippets"
}
fn schema(&self) -> Value {
json!({
"name": "web_search",
"description": "Search the web. Returns titles, URLs, and snippets for your query. Use to find current information, research topics, check facts. One focused search is almost always enough; for factual lookups do NOT re-search with rephrased queries — synthesize promptly. If results are consistently empty, the search backend may be blocked — suggest the user set up Brave Search via manage_config (search.backend = 'brave' + search.api_key).",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query"
},
"max_results": {
"type": "integer",
"description": "Maximum number of results (default 5, max 10)"
}
},
"required": ["query"],
"additionalProperties": false
}
})
}
fn capabilities(&self) -> ToolCapabilities {
ToolCapabilities {
read_only: true,
external_side_effect: true,
needs_approval: false,
idempotent: true,
high_impact_write: false,
}
}
async fn call(&self, arguments: &str) -> anyhow::Result<String> {
let args: Value = serde_json::from_str(arguments)?;
let query = args["query"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: query"))?;
let max_results = args["max_results"]
.as_u64()
.map(|n| n as usize)
.unwrap_or(DEFAULT_MAX_RESULTS)
.clamp(1, MAX_MAX_RESULTS);
let results = self.backend.search(query, max_results).await?;
if results.is_empty() {
return Ok(format!("No results found for: {}", query));
}
let formatted: Vec<String> = results
.iter()
.enumerate()
.map(|(i, r)| {
if r.snippet.is_empty() {
format!("{}. [{}]({})", i + 1, r.title, r.url)
} else {
format!("{}. [{}]({})\n {}", i + 1, r.title, r.url, r.snippet)
}
})
.collect();
Ok(formatted.join("\n\n"))
}
}