rho-coding-agent 0.25.0

A lightweight agent harness inspired by Pi
Documentation
use serde_json::Value;

use crate::{credentials::WebSearchCredential, tool::ToolError};

use super::{SearchBackendConfig, SearchItem};

pub(in crate::tools::web) fn resolve_api_key(config: &SearchBackendConfig) -> Option<String> {
    std::env::var("BRAVE_SEARCH_API_KEY")
        .or_else(|_| std::env::var("BRAVE_API_KEY"))
        .ok()
        .or_else(|| config.credential(WebSearchCredential::Brave))
}

pub(super) async fn search(
    client: &reqwest::Client,
    query: &str,
    num_results: usize,
    recency_filter: Option<&str>,
    domain_filter: Option<&[String]>,
    config: &SearchBackendConfig,
) -> Result<Vec<SearchItem>, ToolError> {
    let key = resolve_api_key(config)
        .ok_or_else(|| ToolError::Message("BRAVE_SEARCH_API_KEY is not set".into()))?;
    let filtered_query = apply_domain_filter(query, domain_filter);
    let count = num_results.to_string();
    let mut request = client
        .get("https://api.search.brave.com/res/v1/web/search")
        .query(&[("q", filtered_query.as_str()), ("count", count.as_str())]);
    if let Some(freshness) = brave_freshness(recency_filter) {
        request = request.query(&[("freshness", freshness)]);
    }
    let response: Value = request
        .header("Accept", "application/json")
        .header("X-Subscription-Token", key)
        .send()
        .await
        .map_err(|err| ToolError::Message(format!("Brave search request failed: {err}")))?
        .error_for_status()
        .map_err(|err| ToolError::Message(format!("Brave search failed: {err}")))?
        .json()
        .await
        .map_err(|err| ToolError::Message(format!("Brave search response was not JSON: {err}")))?;

    let results = response
        .get("web")
        .and_then(|web| web.get("results"))
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
        .take(num_results)
        .map(|item| SearchItem {
            title: item
                .get("title")
                .and_then(Value::as_str)
                .map(str::to_string),
            url: item.get("url").and_then(Value::as_str).map(str::to_string),
            snippet: item
                .get("description")
                .and_then(Value::as_str)
                .unwrap_or_default()
                .to_string(),
        })
        .collect();
    Ok(results)
}

fn apply_domain_filter(query: &str, domain_filter: Option<&[String]>) -> String {
    let Some(filters) = domain_filter else {
        return query.to_string();
    };
    let filters = filters
        .iter()
        .map(|domain| domain.trim())
        .filter(|domain| !domain.is_empty())
        .map(|domain| {
            domain
                .strip_prefix('-')
                .map(|domain| format!("-site:{domain}"))
                .unwrap_or_else(|| format!("site:{domain}"))
        })
        .collect::<Vec<_>>();
    if filters.is_empty() {
        query.to_string()
    } else {
        format!("{} {}", query, filters.join(" "))
    }
}

fn brave_freshness(recency_filter: Option<&str>) -> Option<&'static str> {
    match recency_filter {
        Some("day") => Some("pd"),
        Some("week") => Some("pw"),
        Some("month") => Some("pm"),
        Some("year") => Some("py"),
        Some(_) | None => None,
    }
}